11#!/usr/bin/env bun
22/**
33 * Fails when a sealed-text column (`sealedText(...)` in `packages/db/schema.ts`) is compared or
4- * interpolated in a way that bypasses its sealing.
4+ * read in a way that bypasses its sealing.
55 *
6- * - `comparison`: `eq`/`ne`/`inArray`/`notInArray` against the column. The bound parameter goes
6+ * - `comparison`: a Drizzle comparison operator against the column. Its bound parameter goes
77 * through the column's mapper, so a process that seals writes compares the sealed form and
8- * misses rows stored before sealing. `sealedTextEquals` from `@sim/db/sealed-text` matches both.
8+ * misses rows stored before sealing (ordering and pattern operators are meaningless on
9+ * ciphertext). `sealedTextEquals` from `@sim/db/sealed-text` matches both forms; `isNull` and
10+ * `isNotNull` are unaffected and allowed.
911 * - `raw-interpolation`: `${table.column}` inside a raw `sql` template. A raw expression is read
1012 * or compared as stored, so it sees ciphertext; select the column itself, or use the helper.
11- * - `context-mismatch`: a `sealedText(table, column)` whose table is not the one it is declared in.
12- * The pair is the authenticated context `sealedTextEquals` rebuilds from the column, so a
13- * mismatch makes every equality miss.
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.
15+ *
16+ * Table aliases count as the table: renamed imports from `@sim/db` / `@sim/db/schema` and
17+ * `alias(table, ...)` bindings.
1418 *
1519 * Swept clean when it landed, so there is no baseline: every hit fails.
1620 *
@@ -23,15 +27,42 @@ import { escapeRegExp } from '@sim/utils/string'
2327
2428const ROOT = path . resolve ( path . dirname ( fileURLToPath ( import . meta. url ) ) , '..' )
2529const SCHEMA = 'packages/db/schema.ts'
26- const HELPER = 'packages/db/sealed-text.ts'
30+ const SKIPPED_FILES = new Set ( [
31+ 'packages/db/sealed-text.ts' ,
32+ 'scripts/check-sealed-columns.test.ts' ,
33+ ] )
34+ /** Files that read the stored form on purpose, so only `raw-column-name` skips them. */
35+ const STORED_FORM_READERS =
36+ / (?: \. t e s t \. t s x ? | \. i n t e g r a t i o n \. t s | \/ s c r i p t s \/ t e s t - [ \w - ] + - e 2 e \. t s ) $ | ^ p a c k a g e s \/ d b \/ s c r i p t - m i g r a t i o n s \/ 0 0 3 7 _ s e a l _ a c c o u n t _ t o k e n s \. t s $ /
2737const SCAN_DIRS = [ 'apps' , 'packages' , 'scripts' ]
2838const SKIP_DIRS = new Set ( [ 'node_modules' , '.next' , '.turbo' , 'coverage' , 'dist' , 'build' , 'out' ] )
2939const SOURCE_FILE = / \. (?: t s | t s x | m t s | c t s ) $ /
3040
3141const TABLE_DECLARATION = / e x p o r t c o n s t ( \w + ) = p g T a b l e \( \s * ' ( \w + ) ' / g
3242const SEALED_COLUMN = / ( \w + ) : \s * s e a l e d T e x t \( \s * ' ( \w + ) ' \s * , \s * ' ( \w + ) ' \s * \) / g
43+ const COMPARISON_OPERATORS = [
44+ 'eq' ,
45+ 'ne' ,
46+ 'gt' ,
47+ 'gte' ,
48+ 'lt' ,
49+ 'lte' ,
50+ 'between' ,
51+ 'notBetween' ,
52+ 'like' ,
53+ 'notLike' ,
54+ 'ilike' ,
55+ 'notIlike' ,
56+ 'inArray' ,
57+ 'notInArray' ,
58+ 'arrayContains' ,
59+ 'arrayContained' ,
60+ 'arrayOverlaps' ,
61+ ]
62+ const SQL_TEMPLATE = / \b s q l (?: < [ ^ > \n ] * > ) ? ` ( (?: [ ^ ` \\ ] | \\ .) * ) ` / g
63+ const SQL_RAW = / \b s q l \. r a w \( \s * ( [ ' " ` ] ) ( (?: (? ! \1) [ \s \S ] ) * ) \1/ g
3364
34- type Rule = 'comparison' | 'raw-interpolation' | 'context-mismatch '
65+ type Rule = 'comparison' | 'raw-interpolation' | 'raw-column-name '
3566
3667interface Violation {
3768 file : string
@@ -45,6 +76,8 @@ interface SealedColumn {
4576 table : string
4677 /** The column's property (`accessToken`). */
4778 property : string
79+ /** The physical column name (`access_token`). */
80+ columnName : string
4881 /** How code references it, for reports (`account.accessToken`). */
4982 reference : string
5083}
@@ -53,63 +86,69 @@ function lineOf(source: string, index: number): number {
5386 return source . slice ( 0 , index ) . split ( '\n' ) . length
5487}
5588
56- /** Reads every sealed column from the schema and checks each names the table it is declared in. */
57- export function readSealedColumns ( schemaSource : string ) : {
58- columns : SealedColumn [ ]
59- violations : Violation [ ]
60- } {
89+ /** Reads every sealed column from the schema. */
90+ export function readSealedColumns ( schemaSource : string ) : SealedColumn [ ] {
6191 const tables = [ ...schemaSource . matchAll ( TABLE_DECLARATION ) ] . map ( ( match ) => ( {
6292 exportName : match [ 1 ] ,
63- tableName : match [ 2 ] ,
6493 index : match . index ?? 0 ,
6594 } ) )
6695 const columns : SealedColumn [ ] = [ ]
67- const violations : Violation [ ] = [ ]
6896 for ( const match of schemaSource . matchAll ( SEALED_COLUMN ) ) {
69- const index = match . index ?? 0
70- const [ text , property , tableArg ] = match
71- const table = tables . filter ( ( candidate ) => candidate . index < index ) . at ( - 1 )
97+ const [ , property , , columnName ] = match
98+ const table = tables . filter ( ( candidate ) => candidate . index < ( match . index ?? 0 ) ) . at ( - 1 )
7299 if ( ! table ) continue
73100 columns . push ( {
74101 table : table . exportName ,
75102 property,
103+ columnName,
76104 reference : `${ table . exportName } .${ property } ` ,
77105 } )
78- if ( tableArg !== table . tableName ) {
79- violations . push ( {
80- file : SCHEMA ,
81- line : lineOf ( schemaSource , index ) ,
82- rule : 'context-mismatch' ,
83- text,
84- } )
85- }
86106 }
87- return { columns, violations }
107+ return columns
88108}
89109
90- /** The local names a file binds `table` to: itself, plus any `import { table as alias }` rename. */
110+ /**
111+ * The local names a file binds `table` to: itself, any `import { table as x }` rename, and any
112+ * `const x = alias(table, ...)`.
113+ */
91114function tableNames ( source : string , table : string ) : string [ ] {
92- const rename = new RegExp ( `\\b${ escapeRegExp ( table ) } \\s+as\\s+(\\w+)` , 'g' )
115+ const escaped = escapeRegExp ( table )
116+ const rename = new RegExp ( `\\b${ escaped } \\s+as\\s+(\\w+)` , 'g' )
93117 const imports =
94118 source . match ( / i m p o r t \s + (?: t y p e \s + ) ? \{ [ ^ } ] * \} \s * f r o m \s * ' @ s i m \/ d b (?: \/ s c h e m a ) ? ' / g) ?? [ ]
95- const aliases = imports . flatMap ( ( statement ) => [ ...statement . matchAll ( rename ) ] . map ( ( m ) => m [ 1 ] ) )
96- return [ table , ...aliases ]
119+ const renames = imports . flatMap ( ( statement ) => [ ...statement . matchAll ( rename ) ] . map ( ( m ) => m [ 1 ] ) )
120+ const aliasBinding = new RegExp ( `\\b(\\w+)\\s*=\\s*alias\\(\\s*(?:\\w+\\.)?${ escaped } \\b` , 'g' )
121+ const aliases = [ ...source . matchAll ( aliasBinding ) ] . map ( ( m ) => m [ 1 ] )
122+ return [ table , ...renames , ...aliases ]
97123}
98124
99- /** Finds comparisons and raw interpolations of `columns` in one source file. */
125+ /** Finds comparisons and raw reads of `columns` in one source file. */
100126export function findViolations ( file : string , source : string , columns : SealedColumn [ ] ) : Violation [ ] {
101127 const violations : Violation [ ] = [ ]
128+ const report = ( rule : Rule , index : number , text : string ) =>
129+ violations . push ( { file, line : lineOf ( source , index ) , rule, text } )
130+ const operators = COMPARISON_OPERATORS . join ( '|' )
102131 for ( const { table, property } of columns ) {
103132 const names = tableNames ( source , table ) . map ( escapeRegExp ) . join ( '|' )
104133 const column = `(?:\\w+\\.)?(?:${ names } )\\.${ escapeRegExp ( property ) } \\b`
105134 const patterns : [ Rule , RegExp ] [ ] = [
106- [ 'comparison' , new RegExp ( `\\b(?:eq|ne|inArray|notInArray )\\(\\s*${ column } ` , 'g' ) ] ,
135+ [ 'comparison' , new RegExp ( `\\b(?:${ operators } )\\(\\s*${ column } ` , 'g' ) ] ,
107136 [ 'raw-interpolation' , new RegExp ( `\\$\\{\\s*${ column } \\s*\\}` , 'g' ) ] ,
108137 ]
109138 for ( const [ rule , pattern ] of patterns ) {
110- for ( const match of source . matchAll ( pattern ) ) {
111- violations . push ( { file, line : lineOf ( source , match . index ?? 0 ) , rule, text : match [ 0 ] } )
112- }
139+ for ( const match of source . matchAll ( pattern ) ) report ( rule , match . index ?? 0 , match [ 0 ] )
140+ }
141+ }
142+ if ( STORED_FORM_READERS . test ( file ) ) return violations
143+ const names = new RegExp ( `\\b(?:${ columns . map ( ( c ) => escapeRegExp ( c . columnName ) ) . join ( '|' ) } )\\b` )
144+ for ( const pattern of [ SQL_TEMPLATE , SQL_RAW ] ) {
145+ for ( const match of source . matchAll ( pattern ) ) {
146+ const body = match [ match . length - 1 ] . replace ( / \$ \{ [ ^ } ] * \} / g, ( value ) =>
147+ ' ' . repeat ( value . length )
148+ )
149+ const hit = names . exec ( body )
150+ if ( hit )
151+ report ( 'raw-column-name' , ( match . index ?? 0 ) + match [ 0 ] . indexOf ( body ) + hit . index , hit [ 0 ] )
113152 }
114153 }
115154 return violations
@@ -126,15 +165,16 @@ function* sourceFiles(dir: string): Generator<string> {
126165}
127166
128167if ( import . meta. main ) {
129- const { columns, violations } = readSealedColumns ( readFileSync ( path . join ( ROOT , SCHEMA ) , 'utf8' ) )
168+ const columns = readSealedColumns ( readFileSync ( path . join ( ROOT , SCHEMA ) , 'utf8' ) )
169+ const violations : Violation [ ] = [ ]
130170 if ( columns . length === 0 ) {
131171 console . error ( `No sealedText columns found in ${ SCHEMA } ; update this audit with the schema.` )
132172 process . exit ( 1 )
133173 }
134174 for ( const dir of SCAN_DIRS ) {
135175 for ( const absolute of sourceFiles ( path . join ( ROOT , dir ) ) ) {
136176 const file = path . relative ( ROOT , absolute )
137- if ( file === HELPER || file === 'scripts/check-sealed-columns.test.ts' ) continue
177+ if ( SKIPPED_FILES . has ( file ) ) continue
138178 violations . push ( ...findViolations ( file , readFileSync ( absolute , 'utf8' ) , columns ) )
139179 }
140180 }
0 commit comments