Skip to main content

SEC.INJ.DYNAMIC_IDENTIFIER — A column or sort direction is coming from the request

  • 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 call to orderBy(), orderByDesc(), groupBy(), latest() or oldest() where the column — or orderBy()'s direction — is both unknown at analysis time and visibly coming from the HTTP request.

DB::table('orders')->orderBy($request->input('sort'))->get(); // reported
DB::table('orders')->orderBy('created_at', $request->query('dir')); // reported
Order::query()->orderBy(request()->input('sort'))->get(); // reported

Why you cannot just bind it

A value can be bound. An identifier cannot: the column name is part of the statement's grammar, and no database has a placeholder for it. That is the whole reason this rule exists beside the raw-SQL ones — they can tell you to pass a binding, and here there is nothing to bind to.

Escaping is not the answer either. The fix is an allowlist: compare the input against columns you wrote, and pass one of those.

How to answer it — and the rule already knows these shapes

All three of these silence it, because each leaves the analyzer with a value that can only be one of the strings you wrote:

// 1 — a guard
$sort = $request->input('sort');

if (in_array($sort, ['name', 'created_at'], true)) {
DB::table('orders')->orderBy($sort)->get();
}

// 2 — a constant map
private const SORTABLE = ['newest' => 'created_at', 'name' => 'customer_name'];

DB::table('orders')->orderBy(self::SORTABLE[$key] ?? 'created_at')->get();

// 3 — a match over constants
$column = match ((string) $request->input('sort')) {
'name' => 'customer_name',
default => 'created_at',
};

This is the part that decides whether the rule is worth keeping. Sorting is the most common thing a listing page does, so a rule that reported every dynamic column would be muted in its first week — and the real findings would go with it.

What stays silent, on purpose

  • orderByRaw() and groupByRaw(). Those are raw-SQL fragments and belong to SEC.INJ.RAW_INTERPOLATION. One call never produces two findings under two ids.
  • A dynamic column with no visible request. The allowlist may be one method away, and this rule sees one expression in one scope. That is undetermined — reported as a check that could not answer, never as a pass.
  • orderBy on somebody else's class. The receiver's type decides. orderBy, latest and groupBy are ordinary English and appear on collections, repositories and DTOs everywhere.
  • latest() with no argument. It sorts by the model's timestamp column; nothing arrives from outside.

What it does not claim

  • A syntactic pattern, not taint analysis. It reports that the request is visible in the expression. It makes no claim about what the value contains or whether an attacker can reach it.
  • Validated input still counts. Validation checks a value's shape, and an identifier's danger is not its shape — sort=name and sort=1;DROP differ only by a rule somebody wrote, which this analysis cannot see and will not assume.
  • One expression, one scope. A request value stored on a property in one method and read in another is invisible here.

Turning it on

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

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