Skip to main content

SEC.AUTH.PASSWORD_LITERAL_IN_MIGRATION — A password written into a migration

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

What it reports

A CREATE ROLE, CREATE USER or ALTER ROLE in a migration whose PASSWORD (or ENCRYPTED PASSWORD) clause takes a string literal.

Why this is the only Critical of its family

An over-broad grant is a state somebody can narrow. A password in version control is an event that already happened, and narrowing is not one of the available moves. By the time the finding is read, the credential is in every clone of the repository, in the reflog after somebody "removes" it in a later commit, and in any CI log that echoed the migration. Rotating it means rotating it everywhere it was copied to — and nobody knows where that is.

The value never appears in the finding

Not the literal, not a prefix of it, not its length, not a hash. This rule also omits the Statement: … excerpt that every sibling rule ends on, because that excerpt would carry the literal.

The reason is mechanical rather than squeamish: a finding is serialized into a JSON report, a SARIF file, a CI annotation and an agent artifact — several of which are committed or uploaded. A rule that quoted the secret in the course of reporting it would put it in more places than the migration did. The location above the message is how you find it.

What to do

Reported:

DB::statement("CREATE ROLE app_runtime LOGIN PASSWORD 'hunter2'");

Not reported:

DB::statement('CREATE ROLE app_runtime LOGIN PASSWORD ?', [config('database.connections.pgsql.app_role_password')]);

A binding, an environment read, or a secret manager — anything where the value never becomes part of the statement text. That shape is invisible to this rule precisely because there is nothing in the file to find.

If a credential has already been committed, changing the migration is not the fix. Rotate it, then change the migration so the next one is not committed too.

Where it fires, and where it deliberately does not

The identical statement means different things in different places, so the rule answers three ways:

OriginAnswer
a registered migration pathCritical — it runs against the real database
a seeder, factory or fixturesilence — that is test data
no file behind it (a --file snippet)undetermined, reason origin_unknown

The middle row is not a concession. Most password literals in a repository are seeders, and a rule that shouted at all of them would be wrong on the majority — and a rule that is usually wrong gets switched off, taking the migration case with it.

The bottom row is the honesty line. Passing would hide a real leak; a Critical would fire on evidence the run does not have.

"Migration" means what the framework says it means: database/migrations plus every path a package registered through loadMigrationsFrom(), including subdirectories. A seeder named 2026_01_01_000000_seed_users.php is still a seeder.

What it does not see

  • It reads the statement, not the repository. A password committed once and removed in a later migration is still in the history; this rule sees only the migration in front of it.
  • It cannot tell a real credential from a placeholder. A throwaway password for a role dropped three statements later is reported the same way, because the statement does not say which it is.
  • It says nothing about a password supplied through a binding or an environment variable — the recommended shape, invisible here precisely because the value never becomes part of the statement.

Suppressing it

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

Think twice here. A suppression on this id is a decision to keep a credential in version control, and the reason is what the next person reads when they ask why.