Skip to content

Commit dd4cda0

Browse files
committed
improvement(audit): flag wildcard and identifier reads of sealed columns
1 parent 244d7b7 commit dd4cda0

2 files changed

Lines changed: 21 additions & 4 deletions

File tree

‎scripts/check-sealed-columns.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,15 @@ describe('check-sealed-columns', () => {
6363
`await db.execute(sql\`SELECT id FROM account WHERE id = \${id}\nAND access_token IS NULL\`)`,
6464
"sql.raw('refresh_token IS NULL')",
6565
`sql\`SELECT id FROM oauth_refresh_token WHERE grant = \${'refresh_token'}\``,
66+
"sql`SELECT * FROM account WHERE user_id = 'u'`",
67+
'sql`SELECT a.* FROM account_activity a`',
68+
"sql.identifier('refresh_token')",
6669
].join('\n')
6770
expect(found(source)).toEqual([
6871
['raw-column-name', 2],
6972
['raw-column-name', 3],
73+
['raw-column-name', 5],
74+
['raw-column-name', 7],
7075
])
7176
expect(found(source, 'lib/file.integration.ts')).toEqual([])
7277
expect(found(source, 'packages/db/script-migrations/0037_seal_account_tokens.ts')).toEqual([])

‎scripts/check-sealed-columns.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010
* `isNotNull` are unaffected and allowed.
1111
* - `raw-interpolation`: `${table.column}` inside a raw `sql` template. A raw expression is read
1212
* or compared as stored, so it sees ciphertext; select the column itself, or use the helper.
13-
* - `raw-column-name`: the physical column name inside a `sql` template or `sql.raw(...)` body,
14-
* which reads the stored form the same way. Tests and the sealing backfill read it on purpose.
13+
* - `raw-column-name`: the physical column name inside a `sql` template or `sql.raw(...)` body, a
14+
* `sql.identifier(...)` of it, or a `* FROM <table>` wildcard over its table, all of which read
15+
* the stored form the same way. Tests and the sealing backfill read it on purpose.
1516
*
1617
* Table aliases count as the table: renamed imports from `@sim/db` / `@sim/db/schema` and
1718
* `alias(table, ...)` bindings.
@@ -61,6 +62,7 @@ const COMPARISON_OPERATORS = [
6162
]
6263
const SQL_TEMPLATE = /\bsql(?:<[^>\n]*>)?`((?:[^`\\]|\\.)*)`/g
6364
const SQL_RAW = /\bsql\.raw\(\s*(['"`])((?:(?!\1)[\s\S])*)\1/g
65+
const SQL_IDENTIFIER = /\bsql\.identifier\(\s*(['"`])(\w+)\1/g
6466

6567
type Rule = 'comparison' | 'raw-interpolation' | 'raw-column-name'
6668

@@ -76,6 +78,8 @@ interface SealedColumn {
7678
table: string
7779
/** The column's property (`accessToken`). */
7880
property: string
81+
/** The physical table name (`account`). */
82+
tableName: string
7983
/** The physical column name (`access_token`). */
8084
columnName: string
8185
/** How code references it, for reports (`account.accessToken`). */
@@ -90,6 +94,7 @@ function lineOf(source: string, index: number): number {
9094
export function readSealedColumns(schemaSource: string): SealedColumn[] {
9195
const tables = [...schemaSource.matchAll(TABLE_DECLARATION)].map((match) => ({
9296
exportName: match[1],
97+
tableName: match[2],
9398
index: match.index ?? 0,
9499
}))
95100
const columns: SealedColumn[] = []
@@ -99,6 +104,7 @@ export function readSealedColumns(schemaSource: string): SealedColumn[] {
99104
if (!table) continue
100105
columns.push({
101106
table: table.exportName,
107+
tableName: table.tableName,
102108
property,
103109
columnName,
104110
reference: `${table.exportName}.${property}`,
@@ -140,16 +146,22 @@ export function findViolations(file: string, source: string, columns: SealedColu
140146
}
141147
}
142148
if (STORED_FORM_READERS.test(file)) return violations
143-
const names = new RegExp(`\\b(?:${columns.map((c) => escapeRegExp(c.columnName)).join('|')})\\b`)
149+
const columnNames = new Set(columns.map((column) => column.columnName))
150+
const names = new RegExp(`\\b(?:${[...columnNames].map(escapeRegExp).join('|')})\\b`)
151+
const tables = [...new Set(columns.map((column) => column.tableName))].map(escapeRegExp).join('|')
152+
const wildcard = new RegExp(`\\*\\s*from\\s+(?:"?public"?\\.)?"?(?:${tables})"?(?![\\w"])`, 'i')
144153
for (const pattern of [SQL_TEMPLATE, SQL_RAW]) {
145154
for (const match of source.matchAll(pattern)) {
146155
const capture = match[match.length - 1]
147156
const bodyOffset = (match.index ?? 0) + match[0].lastIndexOf(capture)
148157
const body = capture.replace(/\$\{[^}]*\}/g, (value) => ' '.repeat(value.length))
149-
const hit = names.exec(body)
158+
const hit = names.exec(body) ?? wildcard.exec(body)
150159
if (hit) report('raw-column-name', bodyOffset + hit.index, hit[0])
151160
}
152161
}
162+
for (const match of source.matchAll(SQL_IDENTIFIER)) {
163+
if (columnNames.has(match[2])) report('raw-column-name', match.index ?? 0, match[0])
164+
}
153165
return violations
154166
}
155167

0 commit comments

Comments
 (0)