SEC.PII.UNENCRYPTED_COLUMN — A column that looks like personal data is stored in the clear
- Category: privacy
- Severity: low (strong signal) · info (weak signal)
- Level: 0
- Confidence: deterministic — the name match is a heuristic, and the finding carries that caveat in its own text rather than in this field
- Downtime class: online — the finding is about a column that already exists and proposes no DDL
- Stability: preview
- Suites: security
- Applies to: PostgreSQL and MySQL
What it looks at
Two readings, joined:
- The column name, matched against the bundled privacy dictionary — 101 terms in seven
languages, because a German shop names a column
geburtsdatumwhatever language its reports come back in. Matching is on segment boundaries, never substrings:ibanis not found insidecaribbean_id, anddobis not found insidedobro. - The application's Eloquent casts, for the model that maps to the column's table. A column cast
to
encrypted,encrypted:array,encrypted:collection,encrypted:jsonorencrypted:objectis protected before the value ever reaches the row.
A name that matches and a model that says plain text produces the finding. Anything else does not.
This is a name heuristic, and that is the whole honesty limit
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 distinguishes those. So this rule raises a question with
somebody who can answer it — it does not assert an answer it cannot have.
That is also why both severities sit below the security gate's high default. A heuristic that
broke a pipeline would be switched off within a week, and it would take its true positives with it.
The dictionary splits 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.
A column with no model is UNDETERMINED, never clean
The common case in a real application is neither "encrypted" nor "plain". It is a table no model maps
to, a model that will not construct, a custom cast this package may not execute, or two models
disagreeing about one table. Each of those is reported as undetermined with the reason named.
This matters more here than almost anywhere else in the package: a run that stayed silent about an unexamined column would be indistinguishable from a run over an application that encrypts everything.
Models are discovered the way Laravel's own model:prune discovers them — app/Models, falling back
to app/. A derived class name is then verified by reflection to be declared in the file it came
from, so a project whose autoloader maps that name elsewhere does not get an unrelated class read as
one of its models.
Turning it off for a column you have decided about
The pack is opt-in and off by default. Once it is on, a column you have looked at and decided is fine goes in the ignore list:
'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.
Bad
// app/Models/Customer.php
class Customer extends Model
{
// No casts at all: `iban` is stored exactly as it arrives.
}
Good
// app/Models/Customer.php
class Customer extends Model
{
protected function casts(): array
{
return ['iban' => 'encrypted'];
}
}
Evidence
Laravel documents the encrypted cast family as the supported way to store a value encrypted at
rest, decrypting transparently on access. This rule checks for exactly that declaration and makes no
claim about server-side encryption: pgcrypto leaves a bytea behind and no record of what put it
there, so a catalog reading cannot detect it — which is stated here rather than guessed at.
False positives
Expected, and the reason the severity is what it is. The two shapes are:
- A column whose name suggests personal data and whose contents are not — a label, a reference, 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. This rule cannot see either; the finding is still the correct question to have been asked, and the ignore list is the correct answer to it.