Skip to main content

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:

  1. The column name, matched against the bundled privacy dictionary — 101 terms in seven languages, because a German shop names a column geburtsdatum whatever language its reports come back in. Matching is on segment boundaries, never substrings: iban is not found inside caribbean_id, and dob is not found inside dobro.
  2. 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:json or encrypted:object is 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 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.

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.