Skip to main content

SEC.PRIV.GRANT_ADMIN_IN_MIGRATION — A migration grants an administrative privilege

  • Category: security
  • Severity: medium
  • Level: 0 — reachable at any strictness; the security suite is gated by security.min_severity
  • Confidence: deterministic
  • Stability: stable
  • Suites: lint, security
  • Applies to: MySQL

What it reports

A MySQL GRANT whose privilege list contains a privilege that governs the server rather than the data in it — FILE, PROCESS, RELOAD, SHUTDOWN, CREATE USER, SUPER, and the dynamic privileges that replaced SUPER in 8.0.

The list is a shipped artifact, reconciled against a real MySQL 8.4 by its own test rather than maintained by hand: SUPER was deprecated and split across a family of dynamic privileges, so a check that looked only for SUPER would come back empty on a correctly configured server whose account is an administrator in every way that matters.

Why

SELECT on a table is a question about rows. These are questions about the machine:

  • FILE reads and writes files anywhere the server process can, as the server process. It turns database access into filesystem access on the database host, and it is the privilege an import tool asks for once and nobody narrows afterwards.
  • PROCESS shows every session's currently executing statement, across all accounts — and a statement is where credentials and personal data pass through in the clear.
  • RELOAD flushes logs, caches, tables and the grant tables themselves.
  • SHUTDOWN stops the server.
  • CREATE USER creates, drops, renames and re-authenticates any account, which makes every other restriction on the holder provisional.

What to do

Reported:

DB::statement("GRANT FILE ON *.* TO 'app_runtime'@'localhost'");

Not reported:

DB::statement("GRANT SELECT, INSERT, UPDATE, DELETE ON app.orders TO 'app_runtime'@'localhost'");

The safe form is not a narrower version of the same privilege — there is no narrower FILE. It is a different privilege entirely, and a separate operations account if the administrative capability is genuinely needed by something.

What it does not see

  • It reads the privilege list of one statement. A privilege the account already holds, or inherits through a role, is the audit suite's subject.
  • It cannot tell an application account from an operations account. A backup or replication user legitimately needs several of these.
  • It judges the names the shipped artifact carries. A privilege a later MySQL adds is not reported until that artifact is updated, which is why the artifact is reconciled against a real server rather than written from memory.

Why this always fires together with the scope rule

MySQL will not accept an administrative privilege at any scope narrower than *.*GRANT FILE ON app.* TO … is a syntax error. So a statement that trips this rule necessarily also carries a server-wide scope and trips SEC.PRIV.GRANT_SCOPE_BROAD_IN_MIGRATION. That is the engine's grammar rather than a duplicated finding, and the two ids stay separate because accepting one is not accepting the other.

Not reported on PostgreSQL

None of these is a PostgreSQL privilege. Its administrative rights are role attributes (SUPERUSER, CREATEROLE, BYPASSRLS) set by CREATE ROLE / ALTER ROLE — a different statement with its own rules in this suite.

Suppressing it

// config/sqlens.php
'ignore' => [
['rule' => 'SEC.PRIV.GRANT_ADMIN_IN_MIGRATION', 'reason' => 'why this project accepts it'],
],

A suppression needs a reason, and the reason is read by whoever inherits the project.