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.
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.
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.
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.
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
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.
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// GOOD: scope column and PII twin present
export const tenants = pgTable("tenants", { id: uuid("id") })
export const users = pgTable("users", {
id: uuid("id"),
tenantId: uuid("tenant_id").references(() => tenants.id),
email: text("email"),
emailHash: text("email_hash"),
// api_key: keep it in a KMS or secrets store, not in this table
stripeId: text("stripe_customer_id"), // still Low: a judgement call
})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
| Behaviour | Detail |
|---|---|
| Input | Any indexed file that calls pgTable, mysqlTable or sqliteTable with a quoted table name. No network, no credentials. |
| What is compared | The quoted db_name of each column, never the JS property. A column declared as text() with no name string is not parsed. |
| Severity and confidence | Secret High 0.90, PII Medium 0.80, tenant scope Medium 0.75, payment ID Low 0.70. |
| Tenant check | On 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 check | No skip for names that are already encrypted or hashed, so password_hash is reported. |
| Not implemented | isAdmin and role, soft delete and audit timestamp checks. |