Skip to main content

The security suite

php artisan sqlens:security

sqlens:security examines a connection for security and privacy findings across every suite. It is the one command whose findings are not gated by the strictness level, and that difference is the reason it exists as its own front door rather than as a category flag on sqlens:audit.

Why security has its own axis

The level 0–9 is an appetite: how much taste and rigor a project wants applied to its schema. A team can legitimately sit at level 0 forever.

A superuser role with no password is not a matter of appetite. So security and privacy rules ignore the level gate entirely and answer to a severity axis of their own:

info · low · medium · high · critical

A run blocks when a finding reaches the configured floor. That floor is security.min_severity, and unlike almost everything else in this package it does not ship at its most lenient value — it ships at high.

WhereFloorWhy
shipped defaulthighThe useful setting for a pipeline, which is where this runs.
local profilecriticalA local gate that blocks on a finding you are halfway through fixing is a gate people stop running.
ci profilehigh, pinnedPinned even though it equals the default: a project that lowers its own floor while working a backlog must not silently get a pipeline that blocks on nothing.
predeploy profilehigh, pinnedSame reason, at the strictest moment of the day.

Override per run with --min-severity=, including --min-severity=none to report everything and block on nothing while you adopt.

What it looks at

Six areas, and each rule id names its own:

AreaWhat it examines
SEC.AUTHAuthentication: host-based access rules, roles without a password, deprecated password hashes, wildcard hosts, a password literal in a migration.
SEC.PRIVPrivilege: GRANT ALL, grants to PUBLIC, WITH GRANT OPTION, server-admin and FILE grants, SUPERUSER / CREATEROLE / BYPASSRLS, SECURITY DEFINER routines with a mutable search path, DDL executed at runtime.
SEC.CFGServer configuration: TLS disabled or below a minimum version, transport not required, statement and general logging that captures personal data or secrets, secure_file_priv, local_infile, an end-of-life patch level.
SEC.RLSRow-level security: disabled, no policy, a policy that is always true, FORCE not set, an unrestricted owner.
SEC.INJInjection: raw interpolation into SQL, a dynamic identifier, raw SQL with no stated reason.
SEC.PIIPrivacy: a column that holds personal data and is not encrypted.

Each id maps to a page under rules/ that explains the hazard and what to do about it.

Undetermined is the normal case on a managed database

A great deal of what this suite reads is privileged. On RDS, Cloud SQL, Neon or any other managed offering, some of it is simply not exposed to any role you can be given.

That produces undetermined findings, and they are not failures of the run — they are the run telling you which questions it could not ask. The severity gate reports them as their own number, beside the breach count rather than folded into it: a breach is something the run found, an undetermined is something it could not look at, and on a managed database the second is often the larger.

Two flags decide what that costs:

php artisan sqlens:security --profile=ci # strict: an undetermined fails the run
php artisan sqlens:security --allow-undetermined # report them, do not let them move the exit code

--allow-undetermined hides nothing. Every undetermined stays in the report, counted and with its reason. It changes only whether the exit code moves.

See understanding undetermined for the three ways to resolve one, and least privilege for the role that can answer the most questions while still being read-only.

The reading role

The suite reads the catalog and server settings. It writes nothing, takes no lock of its own, and issues no statement outside catalog and state views.

You can point it at a dedicated connection so the reading role is not the one your application runs as:

'security' => [
'audit_connection' => 'sqlens_audit',
],

The audit role documents what that role needs to be granted — and, more usefully, what it must not be. A reader granted more than it needs is itself a finding this suite would report.

When the server is a throwaway container

Three of the most serious things this suite reports are about the server, not about your schema: trust in pg_hba.conf, ssl = off, and a connection role that is a superuser. On a host somebody deploys onto and operates, every one of them is worth blocking a release for.

In a pipeline they usually describe a container the job creates and destroys. Nothing outside the job can reach it, nothing in it survives, and its settings were chosen by whoever wrote the service block — POSTGRES_USER: postgres makes the connection role a superuser by construction, and the image ships trust. A ci profile run therefore blocks on facts that have nothing to do with the application it was asked to judge.

Say so, and the suite answers accordingly. The declaration belongs to the environment the run happens in, not to the project, so the shipped configuration reads it from one:

SQLENS_SERVER_LIFETIME=disposable

Set it in the pipeline and nowhere else. Unset, the server is persistent. A value that is neither is reported as a misconfiguration rather than quietly read as either.

If you published config/sqlens.php before 0.12.0, your copy still holds the literal 'persistent', and the variable changes nothing until that line reads env('SQLENS_SERVER_LIFETIME', 'persistent'). Setting the key in the file directly still works:

'security' => [
'server' => [
'lifetime' => 'disposable',
],
],

Under disposable, every check whose subject is the server or the role the audit connects as reports not_applicable instead of failing. The finding names what went unjudged and points at sqlens:security and sqlens:audit, which read the same facts when they run against the host that will actually be operated. Run one of them there, where the lifetime stays persistent: the question moves, it is not dropped. sqlens:predeploy is not that command, because it runs no SEC.* rule.

Everything about your schema is judged exactly as it would be anywhere. That is the half worth stating plainly: this is not a way to make a red run green. A grant on a table your migrations created, a missing index, a constraint added under a lock — all unchanged. If it will be deployed onto a real server, it is judged here.

Which checks the declaration covers is decided by the rules themselves, never by a rule-id prefix, because a prefix would be wrong within one release: SEC.PRIV.ROLE_SUPERUSER is an attribute of the connecting role and is withheld, while SEC.PRIV.GRANT_PUBLIC is a grant on one of your own tables and reports as usual. Same prefix, opposite answer.

Two things it deliberately does not do:

  • It is never detected. A container and a production server answer every query identically, so there is nothing to probe — the difference is what you intend. An absent declaration means persistent, and a value that is neither is a configuration error rather than a quiet fall back. Silence keeps every server check reporting, which is the only safe direction.
  • It does not touch an undetermined finding. A check that could not run is still a check that could not run; calling that "not applicable" would turn a reading nobody managed to perform into a reassurance, and strict_undetermined would stop escalating the case it exists for.

Advisories, and the one command that touches the network

The end-of-life data behind SEC.CFG.PATCH_EOL is shipped with the package and refreshed explicitly, never as a side effect of a check:

php artisan sqlens:security --refresh-advisories

This is the only thing in the package that opens a network connection. It runs before the profile, the reporter and anything that connects to your database — refreshing is what you do because a check told you the data was old, so an unreachable database must not be the reason you cannot refresh it.

See advisory data for where the data comes from.

Flags

Flag
--connection=The connection to examine; the resolved default when omitted.
--host=The one read host to address when the connection configures a choice. It refuses a host the connection does not offer.
--profile=local, ci or predeploy.
--min-severity=info|low|medium|high|critical, or none to report without blocking. Beats the profile and the config.
--allow-undeterminedDo not let an undetermined move the exit code.
--strict-tools / --no-strict-toolsWhether a missing external tool is an error or a degradation.
--refresh-advisoriesFetch the end-of-life data and write it where this package reads it.
--format=console, json, github, sarif or agent.
--output=Write the report to this file instead of stdout.

Exit codes are the package-wide four.

Where security findings also appear

The suite is a front door, not a fence. Security rules run inside the other suites too — sqlens:lint reports SEC.INJ and the migration-scoped SEC.PRIV and SEC.AUTH rules against the migrations it is already reading, at any level.

sqlens:security is where you go to examine a database rather than a change to one.

Migrations that ship inside a package

security.include_vendor_migrations decides whether a migration living in vendor/ counts as one of yours. It ships off.

// config/sqlens.php
'security' => ['include_vendor_migrations' => true],

Laravel really does run them — a package registers them with loadMigrationsFrom() and they execute against your database like any other — so the switch is not about what they are. It is about whether you can act on them. A critical finding inside vendor/ has one available fix, and it is not yours to make.

Turn it on when the question is genuinely about the dependency tree: an audit that has to state what everything running against this database does, not only what your own team wrote. Expect findings you cannot fix, and read them as inventory rather than as a backlog.

It governs sqlens:lint as well

The setting lives under security because that is where it was first needed, but it is one switch and it decides one question — does this run look at package migrations at all — for every suite that reads migrations. sqlens:lint obeys it, and off means the file is never loaded and never pretend-executed, rather than read and then discounted.

That distinction was worth closing. A first run after composer require could otherwise fail on findings inside another package's migrations while reporting nothing about the project's own — with an exit code of 1 in a CI job on day one, over statements the team cannot change.

Two runs are deliberately not filtered:

  • A run whose paths you named. --path, sqlens.migration_paths and --file see everything under them, whatever the setting says. Naming a path is you saying these, and quietly dropping part of it would make the argument advisory.
  • sqlens:predeploy. A package's migration runs during a deploy and can take a lock like any other, so a preflight that could not see it would answer its own question wrongly.

If every pending migration turns out to be a package's, the run says so — LINT.SKIPPED.ONLY_VENDOR_MIGRATIONS, an undetermined with a named reason. A run that judged nothing never reports as a run that found nothing wrong.