Skip to main content

SEC.INJ.RAW_INTERPOLATION — A runtime value was built into the statement instead of bound to it

  • Category: security
  • Severity: high
  • Level: 0
  • Downtime class: none — the finding is about a call site in PHP, not about a statement
  • Stability: stable
  • Suites: analyse
  • Applies to: every engine — it reads PHP source, not a server

What it reports

A raw-SQL call site whose text was assembled from something that is not known until the program runs: an interpolated string, a concatenation, a sprintf, an implode.

DB::table('orders')->whereRaw("status = '{$status}'")->get(); // reported
DB::select("SELECT * FROM orders WHERE status = '{$status}'"); // reported

How to answer it

Pass the value as a binding. Almost every method this rule looks at takes one — the exceptions are DB::raw() and DB::unprepared(), which have no bindings parameter at all, and the case where the position itself accepts no parameter. Both are covered further down.

DB::table('orders')->whereRaw('status = ?', [$status])->get(); // silent
DB::select('SELECT * FROM orders WHERE status = ?', [$status]); // silent

That is the whole fix, and it is not a workaround for the rule: a bound value never becomes part of the statement, so it cannot change what the statement does — whatever it contains.

When there is no binding form

Some positions take no parameter, in any database. An identifier, a schema name, a settings name, DDL assembled from an enum — PostgreSQL accepts a placeholder in none of them, so statement('… = ?', [$value]) is not advice you can follow:

DB::statement("CREATE SCHEMA \"{$schema}\""); // an identifier
DB::statement("ALTER TABLE t ADD CHECK (status IN ({$statuses}))"); // DDL from an enum

What the engine's own escaping is worth

Two things a project reaches for instead, and they are not equally strong:

what it guaranteesreported?
$connection->escape($value)PDO::quote() — the result is a value literal. It cannot change the statement's grammar and cannot select a different object, which is exactly what a binding gives you.no
$connection->getQueryGrammar()->wrap($name)the value cannot break out of the identifier quotingyes, with different advice

escape() ends the finding: a value that went through it is no longer raw.

wrap() does not, and the reason is worth knowing before you rely on it. It quotes each segment, splitting on . — so a value carrying a dot reaches across schemas:

$grammar->wrap('other_schema.secrets'); // "other_schema"."secrets"
$grammar->wrap('x"; DROP TABLE users;'); // "x""; DROP TABLE users;" — quoted, no breakout
$grammar->wrap(DB::raw($value)); // $value, verbatim — Expression is passed through

No breakout, but not your choice of object either. So the finding stays and the advice changes: at an identifier position the question is not escaping, it is which names the value is allowed to take — an enum, a match, a constant map. That is the same answer SEC.INJ.DYNAMIC_IDENTIFIER gives for the query builder's identifier positions, for the same reason.

Why an identifier position is not a rule of its own

It was considered, and there are two reasons it stays one rule.

The rule cannot see the position. It reads one call site and never the SQL text, so what it knows is that a value went through wrap(), not where in the statement the value lands. A hand-quoted identifier — "CREATE SCHEMA \"{$schema}\"" — reads exactly like any other interpolation. A rule named for identifier positions would report only the wrapped ones, and every other identifier position would stay here under a name that says it is something else.

A second id would move gates that are green today. PHPStan matches an ignoreErrors identifier exactly, so every site a project already answered with identifier: sqlens.rawSql.interpolation would come back under the new identifier on upgrade. Measured on a consuming application before deciding: three wrap() sites in its application code are answered that way.

What separates the two cases already is enough to act on. The message says which one it is — a wrapped value gets the sentence about constraining it to a set you wrote — and #[RawSql(interpolation: …)] answers exactly the call sites you put it on.

When you have done that and it still reports

Rewriting is not the answer either, and neither is turning the rule off. Say so at the call site:

#[RawSql(
reason: 'partitioned-table DDL; the query builder cannot express PARTITION OF',
interpolation: 'the suffix is a date this method formats — PostgreSQL binds no identifier',
)]
public function createPartition(string $suffix): void
{
DB::statement("CREATE TABLE orders_{$suffix} PARTITION OF orders FOR VALUES …");
}

interpolation: is a second reason and answers a different question from reason:. reason: says why raw SQL was the right tool; it says nothing about a runtime value inside the statement, and it does not clear this rule. That is deliberate rather than strict: the policy annotation exists to satisfy SEC.INJ.RAW_SQL_WITHOUT_REASON, so most annotated code carries one — and if it cleared this rule too, the injection check would be off across most of an adopting codebase. The two are independent in the other direction as well: a reason: your policy mode refuses leaves an interpolation: standing.

It goes on a class, a method, a function, a closure or an arrow function, and it covers everything inside what it sits on — including a call one closure deeper. A class-level annotation covers every statement in the class, which is right for a class whose whole job is DDL over computed names and wrong for one that has a single such method; prefer the narrower placement.

The reason is for the next reader and is never parsed. An empty one does not count, and under policy: strict neither does a placeholder your project has listed.

The path-scoped alternative, and why it is second

Where the annotation does not fit — third-party code, a generated file — the exemption belongs in PHPStan's own mechanism, which is precise about which rule it silences and can be scoped to the directory where these statements live:

parameters:
ignoreErrors:
-
identifier: sqlens.rawSql.interpolation
paths:
- database/migrations

That removes these findings and leaves every other rule reporting — including this package's justification rule in the same files. It is coarser than the annotation in both directions that matter: it carries no reason, so nobody later knows whether the line was considered, and it is scoped by path, so the next interpolation in that directory — one that DOES have a binding available — is silenced with it.

One caveat, because it runs opposite to analyse.exclude_paths: an entry there that matches nothing stops the run. An ignoreErrors entry naming a file the run analyzes, for a finding that file no longer produces, is accepted in silence — measured, rather than assumed from reportUnmatchedIgnoredErrors, which does report an entry a narrowed run never reached but not this case. So an exemption whose call site was rewritten long ago keeps sitting in your configuration looking like protection. Review them when the code under them moves.

analyse.exclude_paths does not reach this rule, and that is deliberate. It governs whether a raw statement owes a written reason; this rule reports where a runtime value ended up. One path list silencing both would let a single line disable half the security surface.

Two things worth knowing before you reach for it. An ignoreErrors entry removes the finding entirely — unlike #[SqlensIgnore], which keeps it and records it as suppressed, nothing counts it afterwards, including sqlens:security. And if you are pointing an AI agent at the analyzer, its own instructions tell it not to add ignore entries; this is the case where a human decides that no binding form exists and the exemption is correct.

What it looks at

Both halves of Laravel's raw surface:

  • the statement sinks on the DB facade and on a connection — select, selectOne, scalar, cursor, statement, unprepared, insert, update, delete, affectingStatement, selectResultSets, selectFromWriteConnection;
  • the fragment methods on the query builder — selectRaw, fromRaw, whereRaw, orWhereRaw, havingRaw, orHavingRaw, orderByRaw, groupByRaw, rawValue, raw.

Eloquent counts. Order::query()->whereRaw(…) and $order->lines()->whereRaw(…) are the same call site as the query builder's, and the rule sees them.

What stays silent, on purpose

Parameterized calls. whereRaw('status = ?', [$status]) is idiomatic Laravel and is never reported. A rule that flagged it would be switched off within a week — taking the real findings with it.

A constant statement. Text assembled from class or global constants is still entirely the author's, so 'ANALYZE '.self::TABLE is not a finding. Neither is a lookup into a constant map, which is the shape a team uses as an allowlist.

whereIntegerInRaw() and its family. They cast every value to an integer before it reaches the statement, and that cast is the mitigation. Reporting them would flag the safe form of exactly the pattern this rule looks for.

An argument the analyzer could not read. A value assembled in another method, a property, a method call — the rule sees one call site and cannot follow it. That is undetermined, not a finding. Reporting doubt at high severity is how a security rule teaches a team to ignore it.

A whereRaw() on somebody else's class. The receiver's type decides, not the method name — a codebase is full of repositories and collections with methods that share a name.

What it does not claim

  • It is a syntactic pattern, not taint analysis. It reports that a runtime value reached the statement's text. It makes no claim that the value is attacker-controlled or reachable from a request — SQLens does not do taint analysis at any point.
  • It never quotes your query. A finding travels into CI logs, SARIF files and agent artifacts, and a reproduced fragment would be a second copy of whatever the statement touched. The message names the shape — interpolation, concatenation — and the method, never the SQL.
  • It sees exactly one call site, with no value flow across function boundaries.

Why High, when its neighbor is Low

SEC.INJ.RAW_SQL_WITHOUT_REASON reports a missing sentence: nobody wrote down why raw SQL was chosen. That is a policy gap.

This one reports that a value reached the statement rather than the parameters — the property that decides whether an injection is possible at all. Whether it is exploitable today depends on what flows into that value, which this rule cannot see and does not pretend to; what it can say is that the safe form was available and not used.

Turning it on

includes:
- vendor/pushery/sqlens-for-laravel/extension.neon

See the analyse suite for how it sits beside Larastan and phpstan-dba.

Sources