dolibarr/dev/tools/phan/plugins/SqlInjectionPlugin.php

1764 lines
59 KiB
PHP
Raw Permalink Normal View History

<?php
/* Copyright (C) 2026 MDW <mdeweerd@users.noreply.github.com>
*/
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();