SAST scanner · reads Drizzle table definitions

Drizzle Schema Analyzer

Drizzle schemas are TypeScript, so the analyzer does not run them. It finds pgTable("name", {...}) calls in the source text, pulls out each column's database name, and applies the same four checks as the Prisma analyzer: plaintext secrets, PII without a twin, tenant scope and payment IDs.

SASTup to High4 checksno networkrepository only

Parsing a table

There is no TypeScript compiler involved. A regular expression finds the table call and another finds the columns. What it extracts decides what every later check can see.

export const users = pgTable("users", { id: uuid("id"), email: text("email"), apiKey: text("api_key"), stripeId: text("stripe_customer_id"), refreshToken: text(), }) table name is the first string argument: "users" pgTable, mysqlTable and sqliteTable are all recognised the body is cut with balanced braces, then scanned for columns pattern: name: type("db_name"), type from the Drizzle column types uuid, text, integer, boolean, timestamp, jsonb, varchar, serial ... 4 columns parsed, each keeps its property name and its db_name apiKey is the JS property, api_key is the column every check reads db_name, so api_key meets the secret indicator (the Prisma analyzer reads the field name as written) refreshToken: text() has no quoted db name, so the pattern skips it a secret declared without a name string is never examined no finding is produced for refreshToken ParsedColumn list for "users" propertytypedb_name (checked) iduuidid emailtextemail apiKeytextapi_key stripeIdtextstripe_customer_id appears at the next step refreshToken: not in the list

1The scanner reads every file that calls pgTable, mysqlTable or sqliteTable with a quoted name. The table name is that string, and the braces after it hold the columns.

2Each property: type("db_name") becomes a column. The type has to be one of the known Drizzle column types, and the quoted name is what gets stored next to the JS property.

3The checks compare the db_name, never the JS property. apiKey is judged as api_key and meets the secret indicator, so a camelCase property over a snake_case column works as intended.

4A column written without a name string, such as refreshToken: text(), does not match the pattern. It is never parsed, so a secret declared that way produces no finding.

Column checks

Every column name is tested against three indicator lists. Each test also looks for a protected twin in the same table, so adding a twin column is how a finding is cleared.

db_name in "users" check that applies outcome id no indicator in the namenothing to look for no finding email PII: equals emailtwin: email_encrypted, email_hash, email_hashed Medium · 0.80PII column without encryption: 'email' api_key secret: contains api_keytwin: api_key_encrypted, _hash, _hashed High · 0.90Secret stored in plaintext: 'api_key' stripe_customer_id payment: stripe_ prefix, _customer_id suffixan ID, not a secret Low · 0.70Payment provider ID stored in plaintext password_hash secret: contains passwordtwin: password_hash_hash (does not exist) High · 0.90the hashed column itself is flagged

1A users table with five columns. For each one the scanner asks whether the name carries a secret, PII or payment indicator, and whether a protected twin sits beside it. The twin lookup is in the same table only.

2email equals a PII indicator and no email_encrypted, _hash or _hashed column exists. Medium, 0.80. A column that already ends in one of those suffixes is skipped by this check.

3api_key contains a secret indicator and has no twin. High, 0.90. The recommendation in the finding is application-level encryption or a KMS.

4stripe_customer_id has a provider prefix and an ID suffix. It is an identifier, not a credential, so Low, 0.70. A stripe_webhook_secret column would be a secret finding instead.

5password_hash contains password, and the secret check has no skip for hashed names, so the hashed column is reported as a plaintext secret. The PII check does skip hashed names.

Tenant scope

This check works across tables, not columns. It first decides whether the schema is multi-tenant, then asks which tables should have carried a tenant_id.

tenants id, name, slug tenant_id: no multi-tenant signal: the check is ona table named like tenants, organizations, orgs ...skipped: on the global listtenants is the root, it needs no tenant_id users id, email, role tenant_id: no Medium · 0.75 missing tenant_idname contains "users", no tenant_id column orders id, user_id, tenant_id tenant_id: yes ok: has a tenant_id columnhas_tenant_id is true, the check returns early invoices id, order_id, amount tenant_id: no Medium · 0.75 missing tenant_idname contains "invoices", no tenant_id column audit_logs id, actor_id, action tenant_id: no skipped: on the global listalso global: webhook_logs, migrations ... posts id, title, body tenant_id: no not examined: name not on the listusers, apps, orders, invoices, settings ... step 1: is this schema multi-tenant at all? One exact table name decides. global tables are exempt, they cannot carry a tenant_id of their own. a table that already has tenant_id passes. 2 findings so far tables outside the scoped-name list are never judged: posts has no tenant_id either, and no finding.

1The tenant check is switched on only if a table is named exactly tenants, organizations, orgs, workspaces or companies (or a singular form). Without such a table it never runs.

2Global tables are skipped by name: tenants, platform_settings, webhook_logs, audit_logs, admin_edit_logs, migrations. They cannot carry a tenant column.

3orders has a tenant_id column. The check returns early for it, so it is judged safe.

4users and invoices carry no tenant_id, and their names contain a scoped word. Each is a Medium finding, 0.75, and the description says to add a tenant_id column with a foreign key to tenants.

5posts also has no tenant_id, but its name is not on the scoped list (users, apps, subscriptions, purchases, orders, invoices, settings, profiles). The check is a name heuristic, so it stays quiet.

Why it matters

Why it matters

Schema decisions have security consequences. PII stored in plaintext is a breach liability: if the database leaks, all personal data is immediately readable. A secret column turns one leaked row into a working credential, and a table without tenant_id gives the query layer nothing to isolate tenants with.

Real-world breach

GitHub (2012). A mass assignment vulnerability in Rails let an attacker add his SSH key to the Rails core repository by exploiting a user_id field that sat directly on the model rather than in a separate association table. A check for isAdmin and role columns is not one of the four checks in the current code.

Flagged vs clean schema

// BAD: tenant mode is on, four findings
export const tenants = pgTable("tenants", { id: uuid("id") })

export const users = pgTable("users", {
  id: uuid("id"),
  email: text("email"),                         // PII, no twin
  apiKey: text("api_key"),                      // secret in plaintext
  stripeId: text("stripe_customer_id"),        // payment ID (Low)
})                                                   // no tenant_id column
Scope note

isAdmin and role columns, a missing deleted_at and missing created_at or updated_at are not checked. The code defines audit column constants but no check uses them. The recommended fix, moving roles into a separate user_roles table, is still good practice.

Limits and scope

BehaviourDetail
InputAny indexed file that calls pgTable, mysqlTable or sqliteTable with a quoted table name. No network, no credentials.
What is comparedThe quoted db_name of each column, never the JS property. A column declared as text() with no name string is not parsed.
Severity and confidenceSecret High 0.90, PII Medium 0.80, tenant scope Medium 0.75, payment ID Low 0.70.
Tenant checkOn only if a table is named exactly tenants, organizations, orgs, workspaces or companies (or a singular form). Flags tables whose lowercase name contains users, apps, subscriptions, purchases, orders, invoices, settings or profiles.
Secret checkNo skip for names that are already encrypted or hashed, so password_hash is reported.
Not implementedisAdmin and role, soft delete and audit timestamp checks.