Skip to main content

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:

  1. The column name, matched against a bundled dictionary of terms in seven languages — because a German shop names a column geburtsdatum whatever language its reports come back in.
  2. Your Eloquent casts, for the model that maps to that column's table. A column cast to encrypted, encrypted:array, encrypted:collection, encrypted:json or encrypted:object is 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 groupSignalSeverity
financial, identifiers, healthstronglow
sensitiveweakinfo

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 metWhat it says
No model maps to the tablemodel_not_found
The model exists and will not constructmodel_not_constructible
casts() depends on request statemodel_casts_not_static
The column is cast through a custom castable classcustom_cast_opaque
Two models map to one table and disagreeconflicting_model_casts
The column stores raw bytesopaque_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.