Skip to main content

SEC.INJ.RAW_SQL_REASON_STALE — a written reason that no longer covers any raw SQL

  • Category: security
  • Severity: info
  • Level: 0
  • Downtime class: none — the finding is about an annotation in PHP, not about a statement
  • Stability: stable
  • Suites: analyse

A #[RawSql(reason: '…')] sits on code that runs no raw SQL. The reason was true when it was written; the code moved, and the annotation stayed.

Why an exemption needs an expiry

SEC.INJ.RAW_SQL_WITHOUT_REASON asks for a reason when the raw SQL is written. Nothing asked again afterwards. Rewrite the call into a query-builder chain, delete the statement, move it to another class — the attribute goes on sitting there, and nobody removes it, because nothing says it is dead.

That is the shape that quietly empties an exemption table. Each entry looks like a decision somebody weighed, so the next reader trusts all of them, including the ones that stopped meaning anything years ago.

Bad

final class OrderReport
{
#[RawSql(reason: 'the query builder cannot express a LATERAL join')]
public function topLineItem(): array
{
return DB::table('order_lines')->where('total', '>', 0)->orderByDesc('total')->limit(1)->get()->all();
}
}

Good

final class OrderReport
{
public function topLineItem(): array
{
return DB::table('order_lines')->where('total', '>', 0)->orderByDesc('total')->limit(1)->get()->all();
}
}

The reason was true when it was written. The LATERAL join became a builder chain, and the attribute stayed — reading for the rest of the project's life as a decision somebody weighed. Delete it, or move it to the code that still runs raw SQL. A reason covering a DB::raw() fragment is NOT stale: the count runs against every raw-SQL call site the suite collects, not only the ones the justification rule reports.

What counts as still justifying something

Any raw-SQL call site the suite collects — a statement, a fragment, an expression, a dynamic identifier — not only the two the policy rule reports on:

// NOT reported: the reason covers a fragment, which is raw SQL the policy rule stays silent about.
#[RawSql(reason: 'a window function the builder has no expression for')]
public function ranked(): void
{
DB::table('orders')->select(DB::raw('row_number() over (order by id)'))->get();
}

Narrowing the count to the reporting pair would call that reason dead and send somebody to delete something true, which is worse than saying nothing.

A class-level annotation is kept alive by a raw statement in any of its methods. So the coarse form is also the one least likely to be reported here — another reason to prefer the method-level one.

Where it says nothing

  • policy: off. The duty does not apply, so there is nothing for an annotation to be stale against.
  • An excluded path. A project that excluded a directory from the duty must not get findings from that directory as a consequence of excluding it.
  • A call reached through a variable or a container binding. The suite sees call sites syntactically, so an annotation covering only such a call reads as stale. That is the same limit its sibling has, stated here because it is the direction that produces a false report rather than a missing one.

Why info

The policy rule says a decision was never written down. This one says a decision was written down and has since stopped applying — the project did what was asked, and the code moved afterwards. Nothing is exposed either way, and rating a tidy-up item at the same weight as a missing reason is how a severity axis stops meaning anything.

What to do

Delete the attribute, or move it to the code that still runs raw SQL. There is no third answer worth having: an annotation you are unsure about is one you can check in the time it takes to read the method.