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.