* QUAL: Add SqlInjectionPlugin to detect unsafe SQL variable usage # QUAL: Add SqlInjectionPlugin to detect unsafe SQL variable usage This commit adds a new SqlInjectionPlugin to the Phan configuration, which helps detect unsafe SQL variable usage in the Dolibarr codebase. The plugin checks for variables used in sql expressions that are not properly escaped, cast, or protected by safe methods. * Qual: Add SqlInjection exceptions to Phan baseline * Qual: Add SqlInjection exceptions to Phan baseline * Qual: Update baseline.txt to remove SqlInjection suppressions The SqlInjection suppressions have been removed from the baseline.txt file. This reenables the notification of "unfixed" cases. * Qual: SQL injection detection for missing quotes on escaped strings # Qual: SQL injection detection for missing quotes on escaped strings In summary: report lines with missing quotes as in `" WHERE ".$db->escape($value)." OR "`. * Qual: Fix code for SqlInjection analysis # Qual: Fix code for SqlInjection analysis Rewrite some escape calls, change escape() in sanitize(), rename variable, ignore SqlInjection notices. * Qual: Update baseline Allow some SqlInjection* notices, to be fixed later or already fixed in develop.
1763 lines
59 KiB
PHP
1763 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();
|