UntrustedSqlConstruction
error native on by defaultGroup: Security
Detect request-derived data reaching a resolved SQL execution sink as SQL syntax without parameter binding or proven context-appropriate validation.
Example
<?php
function bad(PDO $pdo) { $pdo->query('SELECT * FROM people WHERE id = ' . $_GET['id']); }Reported:
- line 2: Bind untrusted values as SQL parameters.
Detection
- Report request-derived data reaching a resolved SQL execution sink as SQL syntax without parameter binding or proven context-appropriate validation.
- Resolve builtin calls and named arguments before checking the contract. Project-local callees are considered only when their bodies or immutable summaries establish the required facts. The default is enabled, with positive proof required.
- For flow-dependent conditions, require a reachable source-to-use path with no intervening invalidation. Unknown calls, ambiguous aliases, recursion or analysis-budget exhaustion cannot establish definite facts.
- D4. The sinks are the builtin
PDO::query,PDO::execandPDO::prepare(first argument), called on a receiver whose single resolved class isPDOor a subclass that does not override the method, plus project wrappers whose summary forwards an argument to one of them. The three methods exist on every supported PHP version, so they are sinks on every target version even when the stub signature in use was introduced later. - The statement text is injectable only when a part that contributes characters to it is. Concatenation, interpolation,
??, both ternary branches (the condition for?:), everymatcharm result, array literal items, a variable with a single reaching definition, the separator and pieces ofimplode/join, the fill value ofarray_filland the repeated text ofstr_repeatare followed part by part. Amatchsubject and a ternary condition only select a result and contribute nothing themselves. Any other shape keeps the flow verdict. A variable is followed to its reaching assignment only when its scope cannot rebind it behind that assignment; a reference alias, a by-reference closure capture,foreachor argument, acatchbinding orstaticdeclaration of the same name,extract(), an assignment through a variable variable, aglobaldeclaration of it or a$GLOBALSaccess that is not a different literal entry keeps the report.
Project method wrappers are followed only when a single visible declaration is fixed by a final receiver class, a final method, or a private method accessible at the call. Open overridable methods, ambiguous declarations, conditional class bodies and trait bodies do not establish a wrapper sink. Static calls must name a static method. These contracts compose through nested project wrappers and refresh when class or method dispatch metadata changes.
A wrapper dependency that reads only array values preserves the numeric safety of an array whose values are all numbers, booleans or null. Array keys, whole-array formatting and mixed key/value dependencies remain untrusted; numeric conversion of extracted keys can establish safety again. Return and reference-output dependencies preserve the same distinction.
Exceptions (no report)
Value parameters, strict finite allowlists, constant queries, known-safe identifier selection and unknown flows are excluded.
Unresolved targets, incomplete required facts, suppressed findings and PHP versions lacking the referenced API or language feature produce no report. User declarations shadowing builtin names are not builtin contracts.
Known boolean or numeric scalar output and resolved builtin
md5,sha1,hashorhash_hmachexadecimal output cannot inject the target text syntax. Raw binary or unknown output modes retain source taint. These narrow text proofs do not establish URL or filesystem-path authorization.Numeric results are never reported: unary
+/-, the arithmetic operators-,*,/,%,**(they yield a number or throw, whatever the operands hold), and resolved counting, measuring, comparing and testing builtins such ascount,sizeof,strlen,mb_strlen,substr_count,strcmp,strpos,ord,crc32,in_array,is_numericorctype_digit, which return only a number or a boolean.+is numeric only when one operand is itself provably a number (numeric literal, cast, arithmetic, or a number-returning builtin); two arrays form a union that keeps request text, so that stays reported. A placeholder list such asimplode(',', array_fill(0, count($ids), '?'))is therefore constant text.Request data that a
switchcompared with constant labels is the matched label inside an arm reached only through string labels that do not read as numbers (not empty and not starting, after whitespace, with a digit, a sign or a dot), and after the switch when every other way out replaced it or left the scope:switch ($sort) { case 'name': case 'date': break; default: $sort = 'name'; }validates$sort. A numeric, empty, boolean or null label, a label expression, a fall-through from another arm, adefaultarm and any other subject prove nothing. The subject is a plain variable or an element of a request superglobal read under one constant key (switch ($_GET['action']), not$_FILES); the element is the matched label only while nothing that may run code is evaluated: a call, an object creation, an assignment, anunset, or the use of a local variable, property, offset of a local or class constant ends it, as does a reference to the superglobal. Aswitch (true)reads each case expression like the condition of anif/elseifchain.A value quoted by
PDO::quoteis enclosed and escaped for the connection: no report. A method of the same name on another class proves nothing.
Fix
No automatic fix: prepared-query rewriting requires preserving all binding and statement semantics.
PHP versions
Apply only on PHP versions supporting the referenced APIs and syntax (within PHP 5.3–8.5). The PDO sinks of D4 apply on all of them. Builtin behavior and argument contracts follow the configured PHP version. Extension-specific calls require resolved extension API symbols; do not treat same-named application functions as extension calls.
Options
None. Enable or disable this rule through normal rule configuration.
Configure
In custos.json:
{
"rules": {
"UntrustedSqlConstruction": {
"enabled": false
}
}
}Suppress
Before the statement or declaration (or the first statement of the file):
// @custos-ignore UntrustedSqlConstruction