The privacy pack
The privacy pack looks at what your columns are called and asks whether the values behind those names are encrypted before they reach the row. It is off by default, it is a heuristic, and it says so in every finding it produces.
Turning it on
// config/sqlens.php
'security' => [
'privacy' => [
'enabled' => true,
],
],
Off is the decision rather than a default nobody got round to changing. Switched off, the rules are not registered at all — they cost no query, no scan and no time. What the run does carry is the fact that the category is inactive, in the reproducibility header: a suite that checked less has to say so.
That is the difference between "off" and "clean". A report from a run with the pack disabled is not a report saying your columns are fine; it is a report that never asked.
What it checks
SEC.PII.UNENCRYPTED_COLUMN joins two readings that are useless apart:
- The column name, matched against a bundled dictionary of terms in seven languages — because a
German shop names a column
geburtsdatumwhatever language its reports come back in. - Your Eloquent casts, for the model that maps to that column's table. A column cast to
encrypted,encrypted:array,encrypted:collection,encrypted:jsonorencrypted:objectis protected before the value is ever stored.
A name that matches and a model that says plain text produces a finding. Anything else does not.
Models are discovered the way Laravel's own model:prune discovers them — app/Models, falling back
to app/.
Two severities, and both stay under the gate
The dictionary sorts its terms into a strong and a weak signal, and the finding is quieter for the weak one:
| Term group | Signal | Severity |
|---|---|---|
financial, identifiers, health | strong | low |
sensitive | weak | info |
iban is an IBAN essentially always. religion is also an ordinary column in a content management
system. Reporting both at the same volume would train a reader to ignore the louder one.
Both values sit below the security gate's high default, so an honest privacy finding does not
break a pipeline that never opted into privacy gating. A project that wants them blocking lowers
security.min_severity — a choice it makes rather than one made for it.
Adding your own terms
'security' => [
'privacy' => [
'enabled' => true,
'extra_terms' => ['versicherungsnummer', 'member_reference'],
],
],
Extra terms are merged into the dictionary in force, never replacing it. They join the
identifiers group, which is the only honest place for them: a project adding a term knows the column
identifies somebody, and guessing a narrower group from the word would be this package inventing a
fact about your data.
To replace the bundled list wholesale, point at a file of your own — repository-relative, like every other path this package accepts:
'security' => [
'privacy' => [
'enabled' => true,
'dictionary' => 'resources/sqlens/our-privacy-terms.json',
'extra_terms' => ['member_reference'],
],
],
extra_terms still applies on top of a dictionary of your own. A file that cannot be read, or one
that contains no usable term, throws — a pack that silently matched nothing would be
indistinguishable from a clean database.
Silencing a column you have decided about
'security' => [
'privacy' => [
'enabled' => true,
'ignore_columns' => [
'orders.notes',
'public.invoices.reference',
],
],
],
Qualified names, because notes on orders is a different question from notes on patients. Both
the short form and the schema-qualified form are accepted.
The limits, stated plainly
It reads names. It never reads a value. A column called iban usually holds an IBAN and sometimes
holds a label for one, and nothing in a database catalog separates those. The pack raises a question
with somebody who can answer it; it does not assert an answer it cannot have.
Matching is on segment boundaries, never substrings. iban is not found inside caribbean_id,
dob is not found inside dobro. Camel case is split and diacritics are folded, so birthDate and a
name written with an umlaut match the same term. A multi-word term matches a run of adjacent
segments: customer_tax_id matches tax_id, and tax_bracket_id does not.
It cannot see encryption applied anywhere but an Eloquent cast. A column pgcrypto wrote and a
column holding an avatar are both bytea; MySQL's blob family says exactly as little. So a binary
column with a matching name is reported as undetermined, never as unprotected — server-side
encryption may suppress a finding here and can never raise one.
A column nobody could examine is undetermined, not clean. The common case in a real application
is neither encrypted nor plain:
| What the pack met | What it says |
|---|---|
| No model maps to the table | model_not_found |
| The model exists and will not construct | model_not_constructible |
casts() depends on request state | model_casts_not_static |
| The column is cast through a custom castable class | custom_cast_opaque |
| Two models map to one table and disagree | conflicting_model_casts |
| The column stores raw bytes | opaque_binary_column |
Each of those is named with its reason. A run that stayed silent about them would be indistinguishable from a run over an application that encrypts everything.
Expect false positives, and treat them as cheap
Two shapes, both expected:
- A column whose name suggests personal data and whose contents are a label, a reference or an
enum. Add it to
ignore_columns. - A column encrypted by something other than an Eloquent cast — an application-level envelope, a column-level key in the database. The pack cannot see either. The finding is still the right question to have been asked, and the ignore list is the right answer to it.
This is why the severities are what they are. A heuristic that broke a build would be switched off within a week, and it would take its true positives with it.
Where this fits, for a team in the EU — and where it stops
The finding text itself names no law. That is deliberate: a technical sentence travels everywhere, and a linter citing a statute reads like legal advice it cannot give. The legal context belongs here, on a page you chose to open.
For teams under the GDPR, the relevant provision is Article 32, which requires appropriate technical measures for the security of processing and names encryption as one of them. A column of personal data stored in the clear is a reasonable thing to have looked at when answering that question, and this pack is a way to find such columns quickly across a schema you did not write.
That is the whole claim. SQLens makes no compliance statement.
It reports what it can read from a catalog and from your models. It does not claim that a database is lawful, that a finding is a violation, that encryption is required for any particular column, or that an empty report satisfies any regulation. Whether a column holds personal data, whether encryption is the appropriate measure for it, and what follows from either — those are questions about your business, your processing and your jurisdiction. A schema reader cannot answer them, and this one does not pretend to.
Stability
SEC.PII.UNENCRYPTED_COLUMN ships as preview. It is opt-in twice over: once for the pack, once for
the tier.
'stability' => ['preview'],
Without that, the rule is not admitted even with the pack enabled — a name heuristic that arrived by upgrading would meet people who never chose one.