*/ declare(strict_types=1); /** * @phan-file-suppress PhanCompatibleTypedProperty * @phan-file-suppress PhanPluginUnreachableCode * @phan-file-suppress PhanUndeclaredClassMethod * @phan-file-suppress PhanUndeclaredExtendedClass * @phan-file-suppress PhanUndeclaredInterface * @phan-file-suppress PhanUndeclaredMethod * @phan-file-suppress PhanUndeclaredTypeProperty * @phan-file-suppress PhanUnreferencedUseNormal * @phan-file-suppress PhanPluginUnknownObjectMethodCall */ /** * Phan plugin to detect unsafe SQL variable usage in Dolibarr codebase. * * This plugin checks for variables used in $sql or $sql_* assignments that are not * properly escaped, cast, or protected by safe methods. */ use ast\Node; use Phan\Config; use Phan\PluginV3; use Phan\PluginV3\PostAnalyzeNodeCapability; /** * Plugin class that registers the SQLinjection visitor. */ final class SqlInjectionPlugin extends PluginV3 implements PostAnalyzeNodeCapability { /** * @var bool If true, enable debug ('debug' option for plugin) * @internal */ public static bool $debugEnabled = false; /** * Constructor */ public function __construct() { // @phpstan-ignore-next-line nullCoalesce.property self::$debugEnabled = (bool) (Config::toArray()['SqlInjectionPlugin']['debug'] ?? false); } /** * Get the class name of the visitor that will be used to analyze nodes. * * @return string The fully qualified class name of the visitor */ public static function getPostAnalyzeNodeVisitorClassName(): string { return SqlInjectionVisitor::class; } } /** * Visitor class that checks for unsafe SQL variable usage. * * Operation principle, on nodes that assign to an 'sql' variable, check * that the expression only has variables that are supposedly escaped * or "escaped" by proper casting of function calls. * * The function `checkExpressionForUnsafeVariables` is the entry point for * checking an expression and it operates recursively (on nodes). * Recursion is stopped when a safe cast, function or method call is found. * When a variable is verified, an issue is emitted if it is unsafe. * * @property-read \Phan\CodeBase $code_base * @property-read \Phan\Language\Context $context */ class SqlInjectionVisitor extends \Phan\PluginV3\PluginAwarePostAnalysisVisitor { /** * @property-read \Phan\CodeBase $code_base * @phpstan-property-read Object $code_base * @property-read \Phan\Language\Context $context * @phpstan-property-read Object $context * @phan-suppress PhanUndeclaredTypeProperty */ /** * List of method names considered safe for SQL values. * * @var string[] */ private const SAFE_METHODS = [ // CommonObject 'quote', // Safe, based on fields definition // DoliDB methods, ... 'escape', // Safe, goal is to escape 'idate', // Safe uses dol_print_date 'jdate', // Safe uses dol_mktime 'order', // Sanitizes arguments 'plimit', // Safe, limits are casted to int 'prefix', // Not fully safe - would be better to define prefix($tablebasename) and protect 'sanitize', // Safe, goal is to cleanup // 'order', // Not safe - fields are not checked 'regexpsql', // Partially safe - $subject is not escaped if $sqlstring is 0 'encrypt', // Safe, results in string // 'decrypt', // Unsafe, decrypted value is unknown // 'ifsql', // Not safe because arguments are not escaped // 'stdevpop', // Not safe because field is not escaped/verified // 'hintindex', // Not safe because field is not escaped/verified // QuickMemo 'getMemosQuery', // Safe, variables protected in function 'getTemplateMemosQuery', // Safe, variables protected in function // Functions 'GETPOSTFLOAT', //Returns float 'GETPOSTINT', // Returns int 'addMailingEventTypeSQL', // Returns sql 'count', // Returns int 'date_format', // Returns formatted string 'dolSqlDateFilter', // Partially safe datefield not checked/escaped 'dol_escape_json', 'dol_hash', // Returns string 'dol_print_date', // Returns formatted string 'dol_sanitizeFileName', // Supposed ok for sql (?) 'dol_strlen', // Returns int 'floatval', // Returns float 'forgeSQLFromUniversalSearchCriteria', // Returns sql 'getDolUserInt', //Returns float 'getEntity', // Returns entity 'getSqlCalEvents', // Returns sql 'intval', // Returns int 'natural_search', 'price2num', // Returns formatted number 'sanititzekey', // Used with array_map 'setEntity', // Returns int 'strlen', // Returns int 'strpos', // Returns int 'transformToSQL', // Returns sql (advtargetemailing) // 'get_exdir', // Not safe if directory could look like SQL (forged, e.g. sql as invoice ref) 'getSQLFactLines', // intracommreport.class.php ]; /** * Regex pattern for safe SQL string characters that can appear after an unclosed quote. * These characters don't need escaping and can appear in SQL string literals. */ private const SAFE_STRING_CHARS_REGEX = '/^[\w\d\/\-\s%_=<>!,\(\)]+$/'; /** * List of methods that require their output to be wrapped in quotes in SQL strings. * These methods escape/clean values but don't add the surrounding quotes. * * @var string[] */ private const METHODS_REQUIRING_QUOTES = [ 'escape', 'escapeforlike', 'idate', 'date_format', 'dol_escape_json', 'dol_hash', 'dol_print_date', 'dol_sanitizeFileName', ]; /** * List of variable names that are safe in SQL context. * * @var string[] */ private const SAFE_SQL_VARIABLES = [ 'table', 'table2', 'table3', 'tables', 'column', 'columns', 'field', 'field2', 'field3', 'fields', 'where', 'table_element', 'key', 'row', 'value', 'tmptable', 'tmparray', 'tmpval', 'fieldid', 'dbtablename', 'dbt_keyfield', 'fieldstoshow', 'fields_label', 'fieldlabel', 'tmpsortfield', 'mode', 'place', 'clause', 'type', 'like', 'tmpdatabase', '_SESSION', 'tabletodelete', 'tabletodrop', 'tablealiastouse', 'tabletuse', 'tablename', 'tabledet', 'table_extraf', 'tables_from_used', 'tables_from', 'dictionarytable', 'aliastablesociete', 'tabletouse', 'tmpdatabase', 'element', 'morewhere', 'sortfield', 'sortorder', 'morefilter', 'morewherefilter', 'selectFields', 'selectFieldsGrouped', 'InfoFieldList', 'extrafieldsTable', 'extrafieldsobjectkey', 'alias_societe_perentity', 'alias_product_perentity', 'excludefilter', 'addFilter', 'tabrowid', 'tabsqlsort', 'tabfieldinsert', 'mode_info', 'amountExpr', 'dateRange', 'countExpr', 'q_escaped', ]; /** * List of properties that are trusted in SQL context. * * @var string[] */ private const TRUSTED_PROPERTIES = [ 'database_name', // DoliDB 'table_element', 'table', 'table2', 'fk_element', 'element', 'join', 'where', 'sortorder', 'table_element_line', 'MAP_CAT_FK', 'MAP_CAT_TABLE', 'MAP_OBJ_TABLE', 'field', 'field_line', 'field_date', 'categ_link', // commandestats 'table_rowid', 'from', 'filtervalue', // advtargetemailing ]; /** * Emit a debug message with file and line context * * @param string $message Debug message * @param bool $forceMsg When true, always report messaga * @return void */ private function debug(string $message, $forceMsg = false): void { if (!SqlInjectionPlugin::$debugEnabled || !$forceMsg) { return; } $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2); $caller = $trace[0] ?? ['file' => 'unknown', 'line' => 0]; $file = $caller['file'] ?? 'unknown'; $line = $caller['line'] ?? 0; $shortFile = basename($file); // @phpstan-ignore-next-line method.notFound $this->emitPluginIssue( $this->code_base, // @phpstan-ignore property.notFound $this->context, // @phpstan-ignore property.notFound $forceMsg ? 'SqlInjectionDebugAlways' : 'SqlInjectionDebug', "[DEBUG {$shortFile}:{$line}] %s", [$message] ); } /** * Check if a quote at the given position is followed by safe SQL characters. * Safe characters don't need escaping and can appear in SQL string literals. * * @param string $str The string to check * @param int $quotePos The position of the quote in the string * @return bool True if the quote is followed by safe characters */ private function quoteFollowedBySafeChars(string $str, int $quotePos): bool { $afterQuote = substr($str, $quotePos + 1); return $afterQuote !== '' && preg_match(self::SAFE_STRING_CHARS_REGEX, $afterQuote); } /** * Check if a node (VAR or PROP) has an accepted name or is a DoliDB instance * * @param Node $node The node to check (VAR or PROP) * @param string[] $acceptedNames List of variable names to accept * @return bool True if name is accepted or variable is DoliDB */ private function isDoliDB(Node $node, array $acceptedNames): bool { if ($node->kind === \ast\AST_PROP) { $expr = $node->children['expr'] ?? null; if ($expr instanceof Node) { return $this->isDoliDB($expr, $acceptedNames); } return false; } if ($node->kind !== \ast\AST_VAR) { return false; } $varName = $node->children['name'] ?? null; if (!is_string($varName)) { return false; } $allAccepted = array_merge($acceptedNames, ['db', 'dbs', 'dbsession', 'database', 'dbconn', 'conn']); if (in_array($varName, $allAccepted, true) || str_contains($varName, 'db') || str_contains($varName, 'DB')) { return true; } try { // @phpstan-ignore-next-line property.notFound $scope = $this->context->getFunctionLikeScope() ?? $this->context->getScope(); if ($scope === null) { return false; } $variable = $scope->getVariableByName($varName); if ($variable === null) { return false; } foreach ($variable->getUnionType()->getTypeSet() as $type) { if (method_exists($type, 'getFQSEN') && $type->getFQSEN() === '\\DoliDB') { return true; } } } catch (\Throwable $e) { // Fall through $this->debug((string) $e); } return false; } /** * Check assignments to SQL variables. * * @param Node $node The assignment node being visited * @return void */ public function visitAssign(Node $node): void { // $this->debug("visitAssign: node=".var_export($node, true)); $var = $node->children['var']; if ($this->isSqlVariable($var)) { $varName = $this->getNodeVar($var); $this->debug("visitAssign: var={$varName}, kind={$var->kind}"); $expr = $node->children['expr'] ?? null; if ($expr instanceof Node) { $this->checkExpressionForUnsafeVariables($expr, $node); } } } /** * Return string representation of VAR or PROP, null is neither * * @param Node $node The var or property node * @return ?string The string representation, or null of not a var nor property */ private function getNodeVar(Node $node): ?string { $varName = null; if ($node->kind === \ast\AST_VAR) { $varName = is_string($node->children['name'] ?? null) ? (string) '$'.$node->children['name'] : '$?'; } elseif ($node->kind === \ast\AST_PROP) { $objName = $this->getNodeVar($node->children['expr']); $prop = $node->children['prop'] ?? '?'; if ($prop instanceof Node) { $prop = $this->getNodeVar($prop); } elseif (!is_string($prop)) { $prop = '....'; } $varName = "$objName->$prop"; } elseif ($node->kind === \ast\AST_DIM) { $dimName = $this->getNodeVar($node->children['expr']); $idx = $node->children['dim'] ?? '?'; if ($idx instanceof Node) { $idx = $this->getNodeVar($idx); } elseif (is_string($idx)) { $idx = "'$idx'"; } elseif (is_int($idx)) { $idx = "$idx"; } else { // $this->debug("idx=".var_export($idx,true)); $idx = '....'; } $varName = "{$dimName}[{$idx}]"; } return $varName; } /** * Handle compound assignments (e.g., $sql .= ...) * * @param Node $node The compound assignment node * @return void */ public function visitAssignOp(Node $node): void { $var = $node->children['var']; if ($this->isSqlVariable($var)) { $varName = $this->getNodeVar($var); // $this->debug("visitAssignOp: var={$varName}, kind={$var->kind}"); $expr = $node->children['expr'] ?? null; if ($expr instanceof Node) { $this->checkExpressionForUnsafeVariables($expr, $node); } } } /** * Check if a node represents a SQL variable. * * @param mixed $node The node to check * @return bool True if the node is a SQL variable */ private function isSqlVariable($node): bool { if (!($node instanceof Node)) { return false; } if ($node->kind === \ast\AST_VAR) { $name = $node->children['name'] ?? null; $lowername = is_string($name) ? (string) strtolower((string) $name) : 'not_a_string'; $result = false; if ( is_string($name) // Must be a string && (!in_array(substr($name, 0, 5), ['resql'])) // Not the result of sql request && ( strpos($name, 'sql') === 0 // Starts with sql || substr($lowername, -3) === 'sql' // Ends with sql || substr($name, -6) === '_query' // Ends with _query || strpos($lowername, 'sanitized') !== false // sanitized in name // || strpos($name, 'filter') === 0 // Too wide match, also filters for html (prefer $sqlFilter) // || substr($name, -6) === 'filter' // Too wide match, also $moreforfilter (prefer $sqlFilter) || ($name !== 'query' && in_array(substr($name, 0, 5), array('query', 'where'))) // Is a (partial) SQL clause (prefer $sqlXXXXX) || in_array($name, ['from', 'join', 'sortorder', 'groupby', 'orderby', 'tabsqlsort', 'tabrowid', 'tabfieldinsert', 'resultsql']) // Is safe SQL ) && (false === strpos($lowername, 'sqlfile')) // Starts with sqlfile (is not an sql variable) && (false === strpos($lowername, 'pathtosql')) // Looks like path to sql file ) { $result = true; } $this->debug("isSqlVariable: name=" . var_export($name, true) . ", result=" . var_export($result, true)); return $result; } // Check for object properties ($obj->sql, $obj->where, $obj->from, $obj->order, etc.) if ($node->kind === \ast\AST_PROP) { $propName = $node->children['prop'] ?? null; if (!is_string($propName)) { $this->debug("isSqlVariable (NOPROPNAME):" . var_export($node, true)); return false; } $result = ( strpos($propName, 'sql') === 0 || strpos($propName, 'sanitized') === 0 || strpos($propName, 'MAP_CAT_') === 0 || strpos($propName, 'MAP_OBJ_') === 0 || in_array($propName, [/*'order',*/ 'where', 'from', 'join', 'sortorder']) // A property that is part of SQL ); $this->debug("isSqlVariable (PROP): prop=" . $this->getNodeVar($node) . ", result=" . var_export($result, true)); return $result; } // Check for array dimensions ($array['sql...'] or $array['...sql']) if ($node->kind === \ast\AST_DIM) { if ($this->isSqlVariable($node->children['expr'] ?? null)) { return true; } // $this->debug("isSqlVariable: kind={$node->kind}"); $dimName = $node->children['dim'] ?? null; // $this->debug("isSqlVariable: dim=".var_export($dim,true)); if (is_string($dimName)) { $lowerDim = strtolower($dimName); if (strpos($dimName, 'sql') === 0 || strpos($lowerDim, 'sql') === 0) { $this->debug("isSqlVariable (DIM): dim={$dimName}, result=true"); return true; } elseif ($dimName === 'takeposterminal') { // Supposing $_SESSION['takeposterminal] return true; } } // Recursively check the expression $expr = $node->children['expr'] ?? null; return $expr instanceof Node && $this->isSqlVariable($expr); } return false; } /** * Recursively check an expression for unsafe variable usage. * * @param mixed $expr The expression to check * @param Node $contextNode The context node for error reporting * @param ?Node $parentNode The parent node in the AST (for context checking) * @return void */ private function checkExpressionForUnsafeVariables($expr, Node $contextNode, ?Node $parentNode = null): void { if (!($expr instanceof Node)) { return; } // $this->debug("checkExpr: kind=".$expr->kind.",expr=".var_export($expr)); switch ($expr->kind) { case \ast\AST_VAR: $this->debug("checkExpr: VAR node"); $this->checkVariable($expr, $contextNode); break; case \ast\AST_PROP: $this->debug("checkExpr: PROP node"); $this->checkVariable($expr, $contextNode); break; case \ast\AST_DIM: $this->debug("checkExpr: DIM node"); $this->checkVariable($expr, $contextNode); break; case \ast\AST_BINARY_OP: $left = $expr->children['left'] ?? null; $right = $expr->children['right'] ?? null; $flags = $expr->flags ?? null; // If this is a string concatenation (.), check for unquoted escape methods if ($flags === \ast\flags\BINARY_CONCAT) { $this->checkConcatenationForUnquotedEscape($expr, $contextNode); } if ($left instanceof Node) { $this->checkExpressionForUnsafeVariables($left, $contextNode, $expr); } if ($right instanceof Node) { $this->checkExpressionForUnsafeVariables($right, $contextNode, $expr); } break; case \ast\AST_CONDITIONAL: $trueExpr = $expr->children['true'] ?? null; $falseExpr = $expr->children['false'] ?? null; $this->debug("checkExpr: CONDITIONAL node"); if ($trueExpr instanceof Node) { $this->checkExpressionForUnsafeVariables($trueExpr, $contextNode, $expr); } if ($falseExpr instanceof Node) { $this->checkExpressionForUnsafeVariables($falseExpr, $contextNode, $expr); } // Intentionally skip condition - it's not part of SQL break; case \ast\AST_CALL: case \ast\AST_METHOD_CALL: $method = $expr->children['expr'] ?? null; $methodKind = $method instanceof Node ? $method->kind : 'null'; $methodName = $method->children['name'] ?? null; $objNode = ($expr->kind === \ast\AST_METHOD_CALL) ? ($expr->children['expr'] ?? null) : null; $this->debug("checkExpr: CALL/METHOD_CALL node, methodKind={$methodKind}, methodName=" . var_export($methodName, true)); // Note: We check for unquoted escape/sanitize in the BINARY_OP case (concatenation) // This handles cases like: "..." . $db->escape($x) . "..." // For non-concatenation cases, we rely on the string context check in BINARY_OP if (!$this->isSafeMethodCall($expr)) { $argsNode = $expr->children['args'] ?? null; if ($argsNode instanceof Node) { $argCount = count($argsNode->children ?? []); // @phpstan-ignore nullCoalesce.property $this->debug("Checking {$argCount} args"); foreach ($argsNode->children ?? [] as $idx => $arg) { // @phpstan-ignore nullCoalesce.property if ($idx == 0) { $isArgsOk = false; } if (in_array($methodName, ['preg_replace'])) { if ($idx == 0) {// && $arg->children['value']) { $isArgsOk = in_array($arg, ['/[^a-zA-Z]/']); // $this->debug("preg_replace$idx=".var_export($arg, true)); } elseif ($isArgsOk && $idx == 1) { $isArgsOk = in_array($arg, ['']); } elseif ($isArgsOk && $idx == 2) { return; } else { $this->checkExpressionForUnsafeVariables($arg, $contextNode, $expr); } // First argument of this function does not need to be safe continue; } $value = $arg; //$arg->children['value'] ?? $arg; if ($value instanceof Node) { $this->checkExpressionForUnsafeVariables($value, $contextNode, $expr); } } } } else { $this->debug("Method is SAFE - skipping args"); } break; case \ast\AST_CAST: $flags = $expr->flags ?? null; // @phpstan-ignore nullCoalesce.property if (is_int($flags) && in_array($flags, [\ast\flags\TYPE_DOUBLE, \ast\flags\TYPE_LONG], true)) { return; } $inner = $expr->children['expr'] ?? null; if ($inner instanceof Node) { $this->checkExpressionForUnsafeVariables($inner, $contextNode, $expr); } break; case \ast\AST_CONST: case \ast\AST_NAME: case \ast\AST_MAGIC_CONST: case \ast\AST_CLASS_CONST: // Safe node types - no variables to check break; default: // For any other node type, recursively check all children $this->debug("Other node kind=".$expr->kind); foreach ($expr->children ?? [] as $child) { // @phpstan-ignore nullCoalesce.property if ($child instanceof Node) { $this->checkExpressionForUnsafeVariables($child, $contextNode, $expr); } } } } /** * Check a concatenation for unquoted escape/sanitize method calls * * @param Node $binaryOp The BINARY_OP node with BINARY_CONCAT flag * @param Node $contextNode The context node for error reporting * @return void */ private function checkConcatenationForUnquotedEscape(Node $binaryOp, Node $contextNode): void { $left = $binaryOp->children['left'] ?? null; $right = $binaryOp->children['right'] ?? null; // Check if either operand is an unquoted escape/sanitize method call if ($left instanceof Node && $this->isMethodCallNeedingQuotes($left)) { $this->checkMethodCallQuotingInOperand($left, $binaryOp, $contextNode, 'left', $right); } if ($right instanceof Node && $this->isMethodCallNeedingQuotes($right)) { $this->checkMethodCallQuotingInOperand($right, $binaryOp, $contextNode, 'right', $left); } } /** * Check if a node is a DoliDB escape/sanitize method call that requires quoting * * @param Node $operand The operand node to check * @return bool True if this is a method call that needs quote checking */ private function isMethodCallNeedingQuotes(Node $operand): bool { if ($operand->kind !== \ast\AST_METHOD_CALL && $operand->kind !== \ast\AST_CALL) { return false; } $methodName = null; $objNode = null; if ($operand->kind === \ast\AST_METHOD_CALL) { $methodName = $operand->children['method'] ?? null; $objNode = $operand->children['expr'] ?? null; } else { $method = $operand->children['expr'] ?? null; if ($method instanceof Node && $method->kind === \ast\AST_NAME) { $methodName = $method->children['name'] ?? null; } } if (!is_string($methodName) || !in_array($methodName, self::METHODS_REQUIRING_QUOTES, true)) { return false; } // Check if it's called on a DoliDB instance if ($operand->kind === \ast\AST_METHOD_CALL && $objNode instanceof Node) { return $this->isDoliDB($objNode, ['this', 'db']); } return false; } /** * Check if a method call in a concatenation operand is properly quoted * * @param Node $operand The operand node to check * @param Node $binaryOp The parent BINARY_OP node * @param Node $contextNode The context node for error reporting * @param string $position Either 'left' or 'right' * @param mixed $oppositeOperand The opposite operand (for getting quote type) * @return void */ private function checkMethodCallQuotingInOperand(Node $operand, Node $binaryOp, Node $contextNode, string $position, $oppositeOperand): void { // This method now only checks the quoting context for method calls that need it // The filtering of which methods need quoting is done in checkConcatenationForUnquotedEscape // Get the expected quote type from the opposite operand $expectedQuoteType = $this->getQuoteTypeFromOperand($oppositeOperand); // Check if the method call is properly quoted if (!$this->isMethodCallInQuotedContext($operand, $binaryOp, $position, $expectedQuoteType, $contextNode)) { $methodDisplay = $this->getNodeVarForMethodCall($operand); // @phpstan-ignore-next-line method.notFound $this->emitPluginIssue( $this->code_base, // @phpstan-ignore property.notFound $this->context, // @phpstan-ignore property.notFound 'SqlInjectionUnquotedEscape', "Method call {$methodDisplay} used in SQL without surrounding quotes. Wrap in single or double quotes, e.g., \"'\" . {$methodDisplay} . \"'\".", [] ); } } /** * Extract the quote type from an operand (if it contains a quote) * * @param mixed $operand The operand to check * @return ?string The quote type ('\'' or '"') or null if no quote found */ private function getQuoteTypeFromOperand($operand): ?string { if (is_string($operand)) { if (strpos($operand, "'") !== false) { return "'"; } if (strpos($operand, '"') !== false) { return '"'; } return null; } if ($operand instanceof Node && $operand->kind === \ast\AST_BINARY_OP && ($operand->flags ?? 0) === \ast\flags\BINARY_CONCAT) { // Recursively check nested concatenation $left = $operand->children['left'] ?? null; $right = $operand->children['right'] ?? null; // Try left first $leftQuote = $this->getQuoteTypeFromOperand($left); if ($leftQuote !== null) { return $leftQuote; } // Then try right return $this->getQuoteTypeFromOperand($right); } // Handle ENCAPS_LIST from string interpolation if ($operand instanceof Node && $operand->kind === \ast\AST_ENCAPS_LIST) { foreach ($operand->children as $child) { if (is_string($child)) { if (strpos($child, "'") !== false) { return "'" ; } if (strpos($child, '"') !== false) { return '"'; } } } return null; } return null; } /** * Check if a method call is properly quoted based on its position in a concatenation * * @param Node $methodCall The method call node * @param Node $binaryOp The parent BINARY_OP node * @param string $position The position ('left' or 'right') in the concatenation * @param ?string $expectedQuoteType The expected quote type from the opposite operand * @param Node|null $contextNode The context node (ASSIGN_OP or ASSIGN) for checking if we're at the top level * @return bool True if properly quoted */ private function isMethodCallInQuotedContext(Node $methodCall, Node $binaryOp, string $position, ?string $expectedQuoteType = null, ?Node $contextNode = null): bool { // Get the sibling operand (the one we're not checking) $siblingOperand = ($position === 'left') ? ($binaryOp->children['right'] ?? null) : ($binaryOp->children['left'] ?? null); // If expectedQuoteType is not provided, compute it from the sibling if ($expectedQuoteType === null) { $expectedQuoteType = $this->getQuoteTypeFromOperand($siblingOperand); } // Helper function to check if the binaryOp is at the top level and should be flagged $checkTopLevel = function () use ($contextNode, $binaryOp, $position, $expectedQuoteType): bool { // If we determined that the method call is preceded by a quote (for right position) // or followed by a quote (for left position), we need to check if it's at the top level // If it is, there's no closing quote after/before the method call if ($contextNode !== null && ($contextNode->kind === \ast\AST_ASSIGN_OP || $contextNode->kind === \ast\AST_ASSIGN)) { $assignExpr = $contextNode->children['expr'] ?? null; if ($assignExpr === $binaryOp) { // This binaryOp IS the direct expression of the assignment // So the method call is at the end/beginning with no closing quote return false; } // The binaryOp is nested, so there might be a closing quote in the outer expression // Assume it's OK if the binaryOp is not the direct expression return true; } // No context node, we can't determine // Assume it's properly quoted return true; }; // In AST, string literals in BINARY_OP are represented as direct string values if (is_string($siblingOperand)) { if ($position === 'left') { // Method call is on the left, check if sibling (right) starts with the expected quote if ($expectedQuoteType !== null) { return strpos($siblingOperand, $expectedQuoteType) === 0; } // Fallback: check for any quote return strpos($siblingOperand, "'") === 0 || strpos($siblingOperand, '"') === 0; } else { // Method call is on the right, check if sibling (left) ends with the expected quote or contains // an unclosed expected quote followed by acceptable characters if ($expectedQuoteType !== null) { $endsWithExpectedQuote = substr($siblingOperand, -1) === $expectedQuoteType; // Check if the left operand contains an unclosed quote of the expected type // followed by acceptable characters $containsUnclosedQuote = false; if (!$endsWithExpectedQuote) { $expectedQuotePos = strrpos($siblingOperand, $expectedQuoteType); if ($expectedQuotePos !== false && $this->quoteFollowedBySafeChars($siblingOperand, $expectedQuotePos)) { $containsUnclosedQuote = true; } } if (!$endsWithExpectedQuote && !$containsUnclosedQuote) { // Left operand doesn't end with the expected quote and doesn't contain an acceptable unclosed quote return false; } // Left operand ends with the expected quote or contains an unclosed expected quote followed by word chars // Now check if this binaryOp is at the top level of the expression return $checkTopLevel(); } // Fallback: use the old logic for any quote type $hasLastQuote = substr($siblingOperand, -1) === "'" || substr($siblingOperand, -1) === '"'; // Check if the left operand contains a quote (not necessarily at the end) // that might be an opening quote for a SQL string literal $containsUnclosedQuote = false; if (!$hasLastQuote) { // Check if there's a quote in the sibling that is followed by safe characters // This would indicate the escape call is being appended to a SQL string literal $lastSingleQuotePos = strrpos($siblingOperand, "'"); $lastDoubleQuotePos = strrpos($siblingOperand, '"'); if ($lastSingleQuotePos !== false && ($lastDoubleQuotePos === false || $lastSingleQuotePos > $lastDoubleQuotePos)) { // Last quote is single quote, check what comes after it if ($this->quoteFollowedBySafeChars($siblingOperand, $lastSingleQuotePos)) { $containsUnclosedQuote = true; } } elseif ($lastDoubleQuotePos !== false) { // Last quote is double quote, check what comes after it if ($this->quoteFollowedBySafeChars($siblingOperand, $lastDoubleQuotePos)) { $containsUnclosedQuote = true; } } } if (!$hasLastQuote && !$containsUnclosedQuote) { // Left operand doesn't end with a quote and doesn't contain an acceptable unclosed quote return false; } // Left operand ends with a quote or contains an unclosed quote followed by word chars // Now check if this binaryOp is at the top level of the expression return $checkTopLevel(); } } // If the sibling is an ENCAPS_LIST (string with variable interpolation), check if it starts/ends with the expected quote if ($siblingOperand instanceof Node && $siblingOperand->kind === \ast\AST_ENCAPS_LIST) { if ($expectedQuoteType !== null) { if ($position === 'left') { // Method is on left, check if the ENCAPS_LIST starts with the expected quote return $this->encapsListHasFirstQuoteOfType($siblingOperand, $expectedQuoteType); } else { // Method is on right, check if the ENCAPS_LIST ends with the expected quote // or contains an unclosed quote of the expected type followed by acceptable characters if ($this->encapsListHasLastQuoteOfType($siblingOperand, $expectedQuoteType)) { // ENCAPS_LIST ends with the expected quote, now check if this is at the top level return $checkTopLevel(); } // Check if the ENCAPS_LIST contains an unclosed quote of the expected type followed by acceptable characters if ($this->encapsListHasUnclosedQuoteOfType($siblingOperand, $expectedQuoteType)) { // ENCAPS_LIST contains an unclosed quote, now check if this is at the top level return $checkTopLevel(); } // Doesn't end with or contain an unclosed quote of the expected type return false; } } else { // Fallback: check for any quote if ($position === 'left') { return $this->encapsListHasFirstQuote($siblingOperand); } else { if ($this->encapsListHasLastQuote($siblingOperand)) { // ENCAPS_LIST ends with a quote, now check if this is at the top level return $checkTopLevel(); } if ($this->encapsListHasUnclosedQuote($siblingOperand)) { // ENCAPS_LIST contains an unclosed quote, now check if this is at the top level return $checkTopLevel(); } // Doesn't end with or contain an unclosed quote return false; } } } // If the sibling is another concatenation, check if it starts/ends with the expected quote // or contains an unclosed expected quote followed by acceptable characters if ($siblingOperand instanceof Node && $siblingOperand->kind === \ast\AST_BINARY_OP && ($siblingOperand->flags ?? 0) === \ast\flags\BINARY_CONCAT) { if ($position === 'left') { // Method is on left, need to find the leftmost string in the sibling concat if ($expectedQuoteType !== null) { return $this->hasFirstQuoteOfType($siblingOperand, $expectedQuoteType); } return $this->hasFirstQuote($siblingOperand); } else { // Method is on right, need to find the rightmost string in the sibling concat // Also check if the concatenation contains a quote followed by word characters if ($expectedQuoteType !== null) { if ($this->hasLastQuoteOfType($siblingOperand, $expectedQuoteType)) { // Concatenation ends with the expected quote, now check if this is at the top level return $checkTopLevel(); } // Check if the concatenation contains an unclosed quote of the expected type followed by word chars if ($this->concatHasUnclosedQuoteOfType($siblingOperand, $expectedQuoteType)) { // Concatenation contains an unclosed quote, now check if this is at the top level return $checkTopLevel(); } // Doesn't end with or contain an unclosed quote of the expected type return false; } if ($this->hasLastQuote($siblingOperand)) { // Concatenation ends with a quote, now check if this is at the top level return $checkTopLevel(); } // Check if the concatenation contains an unclosed quote followed by word chars if ($this->concatHasUnclosedQuote($siblingOperand)) { // Concatenation contains an unclosed quote, now check if this is at the top level return $checkTopLevel(); } // Doesn't end with or contain an unclosed quote return false; } } // If we can't determine, assume it's not properly quoted return false; } /** * Check if a concatenation has a first quote of any type (recursively through nested concatenations) * * @param Node $node The BINARY_OP concatenation node * @return bool True if the concatenation has a first quote */ private function hasFirstQuote(Node $node): bool { if (!($node instanceof Node) || $node->kind !== \ast\AST_BINARY_OP || ($node->flags ?? 0) !== \ast\flags\BINARY_CONCAT) { return false; } $left = $node->children['left'] ?? null; if ($left instanceof Node && $left->kind === \ast\AST_BINARY_OP && ($left->flags ?? 0) === \ast\flags\BINARY_CONCAT) { return $this->hasFirstQuote($left); } if (is_string($left)) { return strpos($left, "'") === 0 || strpos($left, '"') === 0; } // Check if left is an ENCAPS_LIST if ($left instanceof Node && $left->kind === \ast\AST_ENCAPS_LIST) { return $this->encapsListHasFirstQuote($left); } return false; } /** * Check if a concatenation has a last quote of any type (recursively through nested concatenations) * * @param Node $node The BINARY_OP concatenation node * @return bool True if the concatenation has a last quote */ private function hasLastQuote(Node $node): bool { if (!($node instanceof Node) || $node->kind !== \ast\AST_BINARY_OP || ($node->flags ?? 0) !== \ast\flags\BINARY_CONCAT) { return false; } $right = $node->children['right'] ?? null; if ($right instanceof Node && $right->kind === \ast\AST_BINARY_OP && ($right->flags ?? 0) === \ast\flags\BINARY_CONCAT) { return $this->hasLastQuote($right); } if (is_string($right)) { return substr($right, -1) === "'" || substr($right, -1) === '"'; } // Check if right is an ENCAPS_LIST if ($right instanceof Node && $right->kind === \ast\AST_ENCAPS_LIST) { return $this->encapsListHasLastQuote($right); } return false; } /** * Check if a concatenation contains an unclosed quote followed by word characters, numbers, slashes, dashes, or other safe SQL characters * * @param Node $node The BINARY_OP concatenation node * @return bool True if the concatenation contains an unclosed quote followed by acceptable characters */ private function concatHasUnclosedQuote(Node $node): bool { if (!($node instanceof Node) || $node->kind !== \ast\AST_BINARY_OP || ($node->flags ?? 0) !== \ast\flags\BINARY_CONCAT) { return false; } $left = $node->children['left'] ?? null; $right = $node->children['right'] ?? null; // Check right operand first (it's the rightmost) if (is_string($right)) { $lastSingleQuotePos = strrpos($right, "'"); $lastDoubleQuotePos = strrpos($right, '"'); if ($lastSingleQuotePos !== false && ($lastDoubleQuotePos === false || $lastSingleQuotePos > $lastDoubleQuotePos)) { // Last quote is single quote, check what comes after it if ($this->quoteFollowedBySafeChars($right, $lastSingleQuotePos)) { return true; } } elseif ($lastDoubleQuotePos !== false) { // Last quote is double quote, check what comes after it if ($this->quoteFollowedBySafeChars($right, $lastDoubleQuotePos)) { return true; } } } elseif ($right instanceof Node && $right->kind === \ast\AST_ENCAPS_LIST) { // Check if the ENCAPS_LIST contains an unclosed quote if ($this->encapsListHasUnclosedQuote($right)) { return true; } } // Check left operand (recursively) if ($left instanceof Node && $left->kind === \ast\AST_BINARY_OP && ($left->flags ?? 0) === \ast\flags\BINARY_CONCAT) { if ($this->concatHasUnclosedQuote($left)) { return true; } } elseif ($left instanceof Node && $left->kind === \ast\AST_ENCAPS_LIST) { // Check if the ENCAPS_LIST contains an unclosed quote if ($this->encapsListHasUnclosedQuote($left)) { return true; } } // Check left operand if it's a string if (is_string($left)) { $lastSingleQuotePos = strrpos($left, "'"); $lastDoubleQuotePos = strrpos($left, '"'); if ($lastSingleQuotePos !== false && ($lastDoubleQuotePos === false || $lastSingleQuotePos > $lastDoubleQuotePos)) { // Last quote is single quote, check what comes after it if ($this->quoteFollowedBySafeChars($left, $lastSingleQuotePos)) { return true; } } elseif ($lastDoubleQuotePos !== false) { // Last quote is double quote, check what comes after it if ($this->quoteFollowedBySafeChars($left, $lastDoubleQuotePos)) { return true; } } } return false; } /** * Check if a concatenation has a first quote of the specified type (recursively through nested concatenations) * * @param Node $node The BINARY_OP concatenation node * @param string $quoteType The specific quote type to check for ('\'' or '"') * @return bool True if the concatenation has a first quote of the specified type */ private function hasFirstQuoteOfType(Node $node, string $quoteType): bool { if (!($node instanceof Node) || $node->kind !== \ast\AST_BINARY_OP || ($node->flags ?? 0) !== \ast\flags\BINARY_CONCAT) { return false; } $left = $node->children['left'] ?? null; if ($left instanceof Node && $left->kind === \ast\AST_BINARY_OP && ($left->flags ?? 0) === \ast\flags\BINARY_CONCAT) { return $this->hasFirstQuoteOfType($left, $quoteType); } if (is_string($left)) { return strpos($left, $quoteType) === 0; } // Check if left is an ENCAPS_LIST if ($left instanceof Node && $left->kind === \ast\AST_ENCAPS_LIST) { return $this->encapsListHasFirstQuoteOfType($left, $quoteType); } return false; } /** * Check if a concatenation has a last quote of the specified type (recursively through nested concatenations) * * @param Node $node The BINARY_OP concatenation node * @param string $quoteType The specific quote type to check for ('\'' or '"') * @return bool True if the concatenation has a last quote of the specified type */ private function hasLastQuoteOfType(Node $node, string $quoteType): bool { if (!($node instanceof Node) || $node->kind !== \ast\AST_BINARY_OP || ($node->flags ?? 0) !== \ast\flags\BINARY_CONCAT) { return false; } $right = $node->children['right'] ?? null; if ($right instanceof Node && $right->kind === \ast\AST_BINARY_OP && ($right->flags ?? 0) === \ast\flags\BINARY_CONCAT) { return $this->hasLastQuoteOfType($right, $quoteType); } if (is_string($right)) { return substr($right, -1) === $quoteType; } // Check if right is an ENCAPS_LIST if ($right instanceof Node && $right->kind === \ast\AST_ENCAPS_LIST) { return $this->encapsListHasLastQuoteOfType($right, $quoteType); } return false; } /** * Check if a concatenation contains an unclosed quote of a specific type followed by word characters, numbers, slashes, dashes, or other safe SQL characters * * @param Node $node The BINARY_OP concatenation node * @param string $quoteType The specific quote type to check for ('\'' or '"') * @return bool True if the concatenation contains an unclosed quote of the specified type followed by acceptable characters */ private function concatHasUnclosedQuoteOfType(Node $node, string $quoteType): bool { if (!($node instanceof Node) || $node->kind !== \ast\AST_BINARY_OP || ($node->flags ?? 0) !== \ast\flags\BINARY_CONCAT) { return false; } $left = $node->children['left'] ?? null; $right = $node->children['right'] ?? null; // Check right operand first (it's the rightmost) if (is_string($right)) { $quotePos = strrpos($right, $quoteType); if ($quotePos !== false && $this->quoteFollowedBySafeChars($right, $quotePos)) { return true; } } elseif ($right instanceof Node && $right->kind === \ast\AST_ENCAPS_LIST) { // Check if the ENCAPS_LIST contains an unclosed quote of the expected type if ($this->encapsListHasUnclosedQuoteOfType($right, $quoteType)) { return true; } } // Check left operand (recursively) if ($left instanceof Node && $left->kind === \ast\AST_BINARY_OP && ($left->flags ?? 0) === \ast\flags\BINARY_CONCAT) { if ($this->concatHasUnclosedQuoteOfType($left, $quoteType)) { return true; } } elseif ($left instanceof Node && $left->kind === \ast\AST_ENCAPS_LIST) { // Check if the ENCAPS_LIST contains an unclosed quote of the expected type if ($this->encapsListHasUnclosedQuoteOfType($left, $quoteType)) { return true; } } // Check left operand if it's a string if (is_string($left)) { $quotePos = strrpos($left, $quoteType); if ($quotePos !== false && $this->quoteFollowedBySafeChars($left, $quotePos)) { return true; } } return false; } /** * Check if an ENCAPS_LIST starts with a specific quote type * * @param Node $node The ENCAPS_LIST node * @param string $quoteType The specific quote type to check for ('\'' or '"') * @return bool True if the ENCAPS_LIST starts with the specified quote */ private function encapsListHasFirstQuoteOfType(Node $node, string $quoteType): bool { if ($node->kind !== \ast\AST_ENCAPS_LIST) { return false; } // Get the first child foreach ($node->children as $child) { if (is_string($child)) { // Found a string part, check if it starts with the quote return strpos($child, $quoteType) === 0; } // If it's a variable or other node, skip it and continue // We only care about the first string part } return false; } /** * Check if an ENCAPS_LIST ends with a specific quote type * * @param Node $node The ENCAPS_LIST node * @param string $quoteType The specific quote type to check for ('\'' or '"') * @return bool True if the ENCAPS_LIST ends with the specified quote */ private function encapsListHasLastQuoteOfType(Node $node, string $quoteType): bool { if ($node->kind !== \ast\AST_ENCAPS_LIST) { return false; } // Get the children in order and find the last string part $lastString = null; foreach ($node->children as $child) { if (is_string($child)) { $lastString = $child; } // If it's a variable or other node, skip it } if ($lastString !== null) { return substr($lastString, -1) === $quoteType; } return false; } /** * Check if an ENCAPS_LIST contains an unclosed quote of a specific type followed by acceptable characters * * @param Node $node The ENCAPS_LIST node * @param string $quoteType The specific quote type to check for ('\'' or '"') * @return bool True if the ENCAPS_LIST contains an unclosed quote of the specified type followed by acceptable characters */ private function encapsListHasUnclosedQuoteOfType(Node $node, string $quoteType): bool { if ($node->kind !== \ast\AST_ENCAPS_LIST) { return false; } // Get the last string that contains the quote type $lastStringWithQuote = null; foreach ($node->children as $child) { if (is_string($child)) { if (strpos($child, $quoteType) !== false) { $lastStringWithQuote = $child; } } } if ($lastStringWithQuote !== null) { // Find the last occurrence of the quote type $quotePos = strrpos($lastStringWithQuote, $quoteType); if ($quotePos !== false && $this->quoteFollowedBySafeChars($lastStringWithQuote, $quotePos)) { return true; } } return false; } /** * Check if an ENCAPS_LIST starts with any quote * * @param Node $node The ENCAPS_LIST node * @return bool True if the ENCAPS_LIST starts with a quote */ private function encapsListHasFirstQuote(Node $node): bool { if ($node->kind !== \ast\AST_ENCAPS_LIST) { return false; } foreach ($node->children as $child) { if (is_string($child)) { return strpos($child, "'") === 0 || strpos($child, '"') === 0; } } return false; } /** * Check if an ENCAPS_LIST ends with any quote * * @param Node $node The ENCAPS_LIST node * @return bool True if the ENCAPS_LIST ends with a quote */ private function encapsListHasLastQuote(Node $node): bool { if ($node->kind !== \ast\AST_ENCAPS_LIST) { return false; } $lastString = null; foreach ($node->children as $child) { if (is_string($child)) { $lastString = $child; } } if ($lastString !== null) { return substr($lastString, -1) === "'" || substr($lastString, -1) === '"'; } return false; } /** * Check if an ENCAPS_LIST contains an unclosed quote of any type followed by acceptable characters * * @param Node $node The ENCAPS_LIST node * @return bool True if the ENCAPS_LIST contains an unclosed quote followed by acceptable characters */ private function encapsListHasUnclosedQuote(Node $node): bool { if ($node->kind !== \ast\AST_ENCAPS_LIST) { return false; } // Check for single quotes $lastSingleQuoteString = null; $lastDoubleQuoteString = null; foreach ($node->children as $child) { if (is_string($child)) { if (strpos($child, "'") !== false) { $lastSingleQuoteString = $child; } if (strpos($child, '"') !== false) { $lastDoubleQuoteString = $child; } } } // Check single quote if ($lastSingleQuoteString !== null) { $lastSingleQuotePos = strrpos($lastSingleQuoteString, "'"); if ($lastSingleQuotePos !== false && $this->quoteFollowedBySafeChars($lastSingleQuoteString, $lastSingleQuotePos)) { return true; } } // Check double quote if ($lastDoubleQuoteString !== null) { $lastDoubleQuotePos = strrpos($lastDoubleQuoteString, '"'); if ($lastDoubleQuotePos !== false && $this->quoteFollowedBySafeChars($lastDoubleQuoteString, $lastDoubleQuotePos)) { return true; } } return false; } /** * Get a string representation of a method or function call for error messages * * @param Node $node The method or function call node * @return string A string representation of the call */ private function getNodeVarForMethodCall(Node $node): string { if ($node->kind === \ast\AST_METHOD_CALL) { $objNode = $node->children['expr'] ?? null; $methodName = $node->children['method'] ?? '?:'; $objStr = $this->getNodeVar($objNode) ?? '?'; $argsStr = $this->getMethodCallArgs($node); return "{$objStr}->{$methodName}({$argsStr})"; } elseif ($node->kind === \ast\AST_CALL) { $method = $node->children['expr'] ?? null; if ($method instanceof Node && $method->kind === \ast\AST_NAME) { $methodName = $method->children['name'] ?? '?:'; $argsStr = $this->getMethodCallArgs($node); return $methodName . "({$argsStr})"; } return '?()'; } return 'unknown()'; } /** * Get the arguments of a method or function call as a string * * @param Node $node The METHOD_CALL or CALL node * @return string The arguments string */ private function getMethodCallArgs(Node $node): string { $argsNode = $node->children['args'] ?? null; if ($argsNode === null) { return ''; } if (!($argsNode instanceof Node)) { return ''; } // AST_ARG_LIST contains children as an array of nodes if ($argsNode->kind === \ast\AST_ARG_LIST) { $args = []; foreach ($argsNode->children as $child) { if ($child instanceof Node) { $args[] = $this->reconstructArg($child); } else { $args[] = var_export($child, true); } } return implode(', ', $args); } // If it's a single argument (not a list), just reconstruct it return $this->reconstructArg($argsNode); } /** * Reconstruct a single argument from AST node * * @param Node|mixed $argNode The argument node * @return string Reconstructed argument string */ private function reconstructArg($argNode): string { if (is_string($argNode) || is_int($argNode) || is_float($argNode) || is_bool($argNode)) { return var_export($argNode, true); } if (!($argNode instanceof Node)) { return ''; } switch ($argNode->kind) { case \ast\AST_VAR: $name = $argNode->children['name'] ?? '?'; return '\$' . $name; case \ast\AST_PROP: $expr = $argNode->children['expr'] ?? null; $prop = $argNode->children['prop'] ?? '?'; return $this->reconstructArg($expr) . '->' . $prop; case \ast\AST_DIM: $expr = $argNode->children['expr'] ?? null; $dim = $argNode->children['dim'] ?? null; $exprStr = $this->reconstructArg($expr); if ($dim instanceof Node) { $dimStr = $this->reconstructArg($dim); } else { $dimStr = is_string($dim) ? var_export($dim, true) : (is_int($dim) ? $dim : '?'); } return "{$exprStr}[{$dimStr}]"; case \ast\AST_CONST: case \ast\AST_NAME: $name = $argNode->children['name'] ?? '?'; return is_string($name) ? $name : (is_scalar($name) ? (string) $name : '?'); case \ast\AST_CLASS_CONST: $class = $argNode->children['class'] ?? null; $name = $argNode->children['name'] ?? '?'; $classStr = $class instanceof Node ? $this->reconstructArg($class) : (is_string($class) ? $class : '?'); $nameStr = is_string($name) ? $name : (is_scalar($name) ? (string) $name : '?'); return "{$classStr}::{$nameStr}"; case \ast\AST_CALL: case \ast\AST_METHOD_CALL: return $this->getNodeVarForMethodCall($argNode); case \ast\AST_CAST: $expr = $argNode->children['expr'] ?? null; $exprStr = $this->reconstructArg($expr); $flags = $argNode->flags ?? 0; if ($flags === \ast\flags\TYPE_STRING) { return "(string){$exprStr}"; } elseif ($flags === \ast\flags\TYPE_LONG) { return "(int){$exprStr}"; } elseif ($flags === \ast\flags\TYPE_BOOL) { return "(bool){$exprStr}"; } elseif ($flags === \ast\flags\TYPE_DOUBLE) { return "(float){$exprStr}"; } elseif ($flags === \ast\flags\TYPE_ARRAY) { return "(array){$exprStr}"; } elseif ($flags === \ast\flags\TYPE_OBJECT) { return "(object){$exprStr}"; } return "({$exprStr})"; case \ast\AST_BINARY_OP: // For concatenation, reconstruct both sides $left = $argNode->children['left'] ?? null; $right = $argNode->children['right'] ?? null; $leftStr = $this->reconstructArg($left); $rightStr = $this->reconstructArg($right); $flags = $argNode->flags ?? 0; if ($flags === \ast\flags\BINARY_CONCAT) { return $leftStr . '.' . $rightStr; } return $leftStr . $rightStr; default: // For other node types, try to get a string representation return '...'; } } /** * Check if a variable node is protected by a safe method call. * * @param Node $varNode The variable node to check * @return bool True if the variable is protected */ private function isProtected(Node $varNode): bool { if ($varNode->kind !== \ast\AST_PROP) { return false; } $expr = $varNode->children['expr'] ?? null; $propName = $varNode->children['prop'] ?? null; if ($expr instanceof Node && $expr->kind === \ast\AST_VAR) { $varName = $expr->children['name'] ?? null; if (!is_string($varName) || !is_string($propName)) { $this->debug("Non-string name/prop in isProtected"); return false; } if ($this->isDoliDB($expr, ['this', 'db']) && in_array($propName, self::SAFE_METHODS, true)) { $this->debug("TRUE in isProtected: obj={$varName}, prop={$propName}"); return true; } if (in_array($propName, self::TRUSTED_PROPERTIES, true)) { $this->debug("TRUE (trusted) in isProtected: prop={$propName}"); return true; } if ($varName === 'hookmanager' && $propName === 'resPrint') { return true; } if ($propName === 'table_element') { return true; } } // Nested property access: $this->db->prop or $obj->db->prop if ($expr instanceof Node && $expr->kind === \ast\AST_PROP) { $innerProp = $expr->children['prop'] ?? null; $innerExpr = $expr->children['expr'] ?? null; if (is_string($innerProp) && $innerProp === 'db' && $innerExpr instanceof Node) { if ($innerExpr->kind === \ast\AST_VAR) { if ($this->isDoliDB($innerExpr, ['this', 'db'])) { if (in_array($propName, self::SAFE_METHODS, true)) { $this->debug("TRUE in isProtected: nested db->{$propName}"); return true; } } } } } $this->debug("FALSE in isProtected"); return false; } /** * Check a variable usage and emit an issue if it's unsafe. * * @param Node $varNode The variable node to check * @param Node $contextNode The context node for error reporting * @return void */ private function checkVariable(Node $varNode, Node $contextNode): void { if ($this->isSqlVariable($varNode)) { // Already (checked) sql variable return; } if ($varNode->kind === \ast\AST_VAR) { $varName = $varNode->children['name'] ?? null; if (!is_string($varName)) { return; } $this->debug("checkVariable: VAR: name={$varName}"); if ( $this->isSqlVariable($varNode) || strpos($varName, 'SqlList') !== false // Not checking sanitization || $this->isProtected($varNode) || in_array($varName, self::SAFE_SQL_VARIABLES, true) ) { $this->debug("VAR {$varName} is SAFE"); return; } // @phpstan-ignore-next-line method.notFound $this->emitPluginIssue( $this->code_base, // @phpstan-ignore property.notFound $this->context, // @phpstan-ignore property.notFound 'SqlInjection', "Variable \${$varName} used in SQL without protection.", [] ); } elseif ($varNode->kind === \ast\AST_PROP) { $expr = $varNode->children['expr'] ?? null; if ($expr instanceof Node && $expr->kind === \ast\AST_VAR) { $varName = $this->getNodeVar($varNode); $this->debug("checkVariable: PROP: {$varName}"); if ($this->isProtected($varNode)) { $this->debug("PROP {$varName} is SAFE"); return; } // @phpstan-ignore-next-line method.notFound $this->emitPluginIssue( $this->code_base, // @phpstan-ignore property.notFound $this->context, // @phpstan-ignore property.notFound 'SqlInjection', "Property {$varName} used in SQL without protection.", [] ); } } elseif ($varNode->kind === \ast\AST_DIM) { // $this->debug("checkVariable: DIM: node=".var_export($varNode,true)); $varName = $this->getNodeVar($varNode); $this->emitPluginIssue( $this->code_base, // @phpstan-ignore property.notFound $this->context, // @phpstan-ignore property.notFound 'SqlInjection', "Property {$varName} used in SQL without protection.", [] ); } } /** * Check a that the method call returns a safe value * * The method call is safe if the: * - Variable or property has an accepted name, or * it is of type 'DoliDB' * - and, the method is on the safe list. * * @param Node $node The method call node to check * @return bool */ private function isSafeMethodCall(Node $node): bool { if ($node->kind !== \ast\AST_CALL && $node->kind !== \ast\AST_METHOD_CALL) { $this->debug("Not CALL or METHOD_CALL"); return false; } $methodName = null; $objNode = null; if ($node->kind === \ast\AST_METHOD_CALL) { // For METHOD_CALL: $obj->method() $methodName = $node->children['method'] ?? null; $objNode = $node->children['expr'] ?? null; $this->debug("METHOD_CALL: methodName=" . var_export($methodName, true) . ", objNode kind=" . ($objNode instanceof Node ? $objNode->kind : 'null')); } else { // For CALL: method() $method = $node->children['expr'] ?? null; if ($method instanceof Node && $method->kind === \ast\AST_NAME) { $methodName = $method->children['name'] ?? null; } $this->debug("CALL: methodName=" . var_export($methodName, true)); } if (!is_string($methodName)) { $this->debug("methodName not string"); return false; } if ($methodName === 'array_map') { // Handle array_map return $this->isArrayMapSafe($node); } // Check if method is in safe list if (!in_array($methodName, self::SAFE_METHODS, true)) { $this->debug("Method '{$methodName}' not in safe list"); return false; } // For function calls (no object), it's always safe (TODO: maybe exclude function calls...) if ($objNode === null) { $this->debug("Function call '{$methodName}' is safe"); return true; } // For method calls, check the object if ($this->isDoliDB($objNode, ['this', 'db', 'staticMemo', 'memoStatic',])) { $this->debug("Method '{$methodName}' on accepted object is safe"); return true; } $this->debug("Method '{$methodName}' on unaccepted object"); return false; } /** * Check if an array_map call has a safe callback * * @param Node $node The array_map call node * @return bool True if the callback is safe */ private function isArrayMapSafe(Node $node): bool { $argsNode = $node->children['args'] ?? null; if (!$argsNode instanceof Node || count($argsNode->children) < 2) { return false; } // First argument is the callback $callback = $argsNode->children[0] ?? null; // $this->debug("isArrayMapSafe: ARRAY callback=" . var_export($callback, true)); // Case 1: callback is a string literal (e.g., 'escape') if (is_string($callback)) { if (!in_array($callback, self::SAFE_METHODS, true)) { $this->debug("Method '{$methodName}' not in safe list"); return false; } else { // TODO: restrict to functions, not "SAFE_METHODS" return true; } } if (!$callback instanceof Node) { return false; } // Case 2: callback is array($obj, 'method') format if ($callback->kind === \ast\AST_ARRAY) { $elems = $callback->children ?? []; if (count($elems) !== 2) { return false; } $obj = $elems[0]->children['value'] ?? null; $method = $elems[1] ?? null; // $this->debug("isArrayMapSafe: ARRAY obj=" . var_export($obj, true). ",method=".var_export($method,true)); if (!$obj instanceof Node || !$method instanceof Node) { return false; } if ($method->kind === \ast\AST_ARRAY_ELEM) { $methodName = $method->children['value'] ?? null; if (!is_string($methodName) || !in_array($methodName, self::SAFE_METHODS, true)) { return false; } // Method is ok, if DoliDb return $this->isDoliDB($obj, ['this', 'db']); } } // Case 3: callback is a closure - can't verify, assume unsafe return false; } } return new SqlInjectionPlugin();