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:
FILEreads 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.PROCESSshows every session's currently executing statement, across all accounts — and a statement is where credentials and personal data pass through in the clear.RELOADflushes logs, caches, tables and the grant tables themselves.SHUTDOWNstops the server.CREATE USERcreates, 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.