1764 lines
59 KiB
PHP
1764 lines
59 KiB
PHP
|
|
<?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();
|