SAST scanner · reads schema.prisma

Prisma Schema Analyzer

A database schema is a statement about what the application stores. This analyzer reads every model block in the repository, goes through the fields one by one, and reports the ones that hold a secret, personal data or a payment ID without a protected twin, and the models that hold tenant data without a tenant column.

SASTup to High4 checksno networkrepository only

Field by field

The actor is the scanner alone. It parses the schema into models and fields, then runs four checks on each model. Watch which field lights up and which rule it satisfies.

model Organization { id String @id } model UserSettings { id String @id email String @unique access_token String stripe_customer_id String user User @relation(...) } skipped kept 4 scalar fields: id, email, access_token, stripe_customer_id skipped: user (a line with @relation is a virtual field, not a column) also skipped: comments, @@map / @@index lines, the _prisma_migrations model access_token contains the indicator access_token looked for access_token_encrypted, _hash, _hashed: none exist Secret stored in plaintext: 'access_token' in model 'UserSettings' email equals the indicator email exactly looked for email_encrypted, email_hash, email_hashed: none exist PII field without encryption: 'email' in model 'UserSettings' Organization is on the multi-tenant list, so this check is on UserSettings contains "settings" and has no tenant_id field Model 'UserSettings' missing tenantId in multi-tenant app stripe_ is a provider prefix and _customer_id is an ID suffix stripe_secret_key would not land here: check 1 owns secrets Payment provider ID stored in plaintext: 'stripe_customer_id' 1 Secret in plaintext name holds secret, api_key, password, access_token ... and no <name>_encrypted / _hash / _hashed field High · 0.90 2 PII without encryption name is, or ends or starts with, email, phone, ssn, address ... skipped when the field is itself _encrypted / _hash / _hashed Medium · 0.80 3 Missing tenant_id runs only if a Tenant / Organization / Workspace model exists and the model name contains users, orders, settings ... Medium · 0.75 4 Payment provider ID prefix stripe_ paypal_ square_ adyen_ braintree_ ... plus an ID suffix: _customer_id, _price_id, _invoice_id ... Low · 0.70

1The scanner reads every file that contains model Name {, cuts the body with balanced braces and keeps only lines whose type is a Prisma scalar. A line with @relation is a virtual field and is skipped, so user never becomes a column.

2Check 1, secrets. The name contains an indicator such as access_token, and no field called <name>_encrypted, _hash or _hashed sits beside it. No twin, so High with confidence 0.90.

3Check 2, PII. email equals an indicator and has no encrypted twin. Medium, 0.80. A field that already ends in _encrypted, _hash or _hashed is skipped, so the twin is never flagged as PII.

4Check 3, tenant scope. It only runs when the schema has a model named like Tenant or Organization. UserSettings matches the scoped list and has no tenant_id. Medium, 0.75.

5Check 4, payment IDs. A provider prefix plus an ID suffix. Low, 0.70. Every finding carries the model, the field and the file, but no line number.

What clears a finding

Each finding names a missing twin or a missing column. Add it and the finding goes away, with one exception that the secret check makes visible.

model UserSettings id String @id + add tenant_id tenant_id String email String @unique + add email_hash email_hash String access_token String + add access_token_encrypted access_token_encrypted String stripe_customer_id String Findings Secret in plaintext: access_tokenSecret in plaintext: access_token_encryptedHigh · 0.90 PII without encryption: emailMedium · 0.80cleared Missing tenant_id: UserSettingsMedium · 0.75cleared Payment ID in plaintext: stripe_customer_idLow · 0.70 4 findings 3 findings 2 findings adding a twin field or the scope field is what clears a finding email now has email_hash; email_hash itself is skipped (it ends in _hash) tenant_id is present, so the tenant check returns early the new name still contains access_token, so it is flagged itself no rule clears a payment ID; it stays Low as a prompt to decide

1Start from the four findings of the walk. The left side is the model, the right side is what the analyzer reports for it.

2Add email_hash. The PII check looks for email plus a suffix in the same model, finds it, and the finding is cleared. The new field is not itself flagged.

3Add tenant_id. The tenant check returns early for a model that already has the column.

4Add access_token_encrypted. The secret check has no skip for names that are already encrypted, so the new field matches the same access_token indicator and is flagged in its place. The finding text recommends application-level encryption or a KMS.

5The payment ID has no twin rule. It stays at Low as a prompt to decide whether the identifier belongs in the threat model. Two findings are left.

Naming rules

The analyzer shares its word lists with the Drizzle analyzer. Drizzle checks the database column name, which is snake_case by habit. Prisma checks the field name as written, which is camelCase by habit.

field in schema.prisma what the check compares outcome tenantId String needs the exact name tenant_id has_tenant_id = name in ("tenant_id",) flagged although tenant scope exists Medium · Model 'UserSettings' missing tenantId apiKey String lowered name is apikey the indicator is api_key, with the underscore silent: the secret is not reported no finding for apiKey emailAddress String lowered name is emailaddress needs == email or a _email suffix silent: the PII field is not reported no finding for emailAddress password_hash String lowered name contains password password_hash is itself the protected form flagged although it is the hashed form High · the secret check has no skip for hashed names The parser takes the name written in the schema, so @map("tenant_id") does not change what is compared.

1The four checks compare field names against lowercase snake_case lists. Prisma schemas are usually camelCase, so what is found depends on how a field is spelled. Four spellings, one rule each.

2tenantId is not tenant_id. A model that has the column under its camelCase name is still reported as missing it. This is a false positive.

3apiKey lowers to apikey, which does not contain api_key. A real secret field goes unreported.

4emailAddress lowers to emailaddress. The PII rule wants email exactly or an underscore boundary, so it is missed. email_address would be caught.

5password_hash contains password. The PII check skips hashed names, the secret check does not, so the hashed column is reported as a plaintext secret.

Why it matters

Why it matters

Schema design decides how bad a breach is. PII stored in plaintext becomes a liability when, not if, the database leaks, and a secret column means one leaked row is a working credential. A model without a tenant column has nothing to filter on, so a cross-tenant read is one forgotten where clause away.

Real-world context

GitHub (2012). A mass assignment bug let an attacker add his SSH key to the Rails core repository through a field that sat directly on the model. That specific pattern, an isAdmin or role field, is not one of the four checks the analyzer runs today.

Flagged vs clean schema

// BAD: four findings
model Organization { id String @id }

model UserSettings {
  id                 String @id
  email              String @unique      // PII, no email_hash
  access_token       String              // secret in plaintext
  stripe_customer_id String              // payment ID (Low)
}                                                   // no tenant_id
Scope note

The analyzer does not check isAdmin or role fields as mass assignment targets, missing createdAt and updatedAt, or a missing deletedAt. Its recommended fix, a separate Role model instead of an isAdmin column, is still sound schema design.

Limits and scope

BehaviourDetail
InputAny indexed file that contains model Name {. No network, no credentials, no depth setting.
Fields it seesOnly scalar types: String, Int, BigInt, Float, Decimal, Boolean, DateTime, Json, Bytes. Enum-typed fields and relation lines are skipped.
Severity and confidenceSecret High 0.90, PII Medium 0.80, tenant scope Medium 0.75, payment ID Low 0.70.
Tenant checkRuns only when a model named Tenant, Organization, Org, Workspace or Company (or a lowercase plural) exists. It flags models whose lowercase name contains users, apps, subscriptions, purchases, orders, invoices, settings or profiles, so a singular Order is not examined.
Secret checkNo skip for names that are already encrypted or hashed, so password_hash and access_token_encrypted are reported.
Name matchingIndicators are lowercase snake_case. camelCase spellings such as apiKey, emailAddress and tenantId do not match.
Not implementedisAdmin and role fields, audit timestamp and soft delete checks.