kryptheon-night 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/schema.js ADDED
@@ -0,0 +1,922 @@
1
+ // Reading a backend's shape, and rebuilding it somewhere safe.
2
+ //
3
+ // The night shift never touches the live app. It reads the shape - tables,
4
+ // columns, constraints, whether row level security is on, and the policies
5
+ // themselves - rebuilds that shape in a throwaway database, seeds fake people
6
+ // into it, and attacks that.
7
+ //
8
+ // The thing to be careful about is not the copying. It is that a copy which
9
+ // differs from the original, even slightly, produces verdicts about a database
10
+ // nobody is running. A policy that comes across subtly changed is worse than no
11
+ // copy at all: it looks like a real answer. So everything here is built to fail
12
+ // loudly rather than to produce an approximate copy quietly - see
13
+ // `unsupported` below, which is checked by the caller and is not advisory.
14
+ //
15
+ // What is deliberately not copied: data. None of it, ever - that is the whole
16
+ // promise. Also not copied: triggers and functions.
17
+ //
18
+ // What IS copied, each because leaving it out changed an answer:
19
+ //
20
+ // unique indexes - they decide whether the same thing can exist twice. An
21
+ // app that enforces uniqueness with CREATE UNIQUE INDEX rather than a
22
+ // UNIQUE constraint would arrive at the copy with none of it, and be
23
+ // reported as broken for having got it right.
24
+ // views - a view runs with its creator's rights unless it says otherwise,
25
+ // so one over a protected table hands out every row in it. Skipping them
26
+ // meant never looking at that door.
27
+ // enums and domains - the app's own types. Borrowed from the original,
28
+ // they made the copy depend on it; and a view that mentions an enum took
29
+ // the scan down outright.
30
+
31
+ /* --------------------------------------------------------------------------
32
+ Reading.
33
+ -------------------------------------------------------------------------- */
34
+
35
+ /** Every table in a schema, with whether row level security is switched on. */
36
+ async function readTables(client, schema) {
37
+ const { rows } = await client.query(
38
+ `SELECT c.relname AS name,
39
+ c.relrowsecurity AS rls_enabled,
40
+ c.relforcerowsecurity AS rls_forced
41
+ FROM pg_class c
42
+ JOIN pg_namespace n ON n.oid = c.relnamespace
43
+ WHERE n.nspname = $1 AND c.relkind = 'r'
44
+ ORDER BY c.relname`,
45
+ [schema],
46
+ );
47
+ return rows;
48
+ }
49
+
50
+ /**
51
+ * The columns of one table, as Postgres itself would write them.
52
+ *
53
+ * format_type is used rather than information_schema because it gives the type
54
+ * back exactly - numeric(10,2) stays numeric(10,2) - and a column that comes
55
+ * back as the wrong width can change what a policy comparison does.
56
+ */
57
+ async function readColumns(client, schema, table) {
58
+ const { rows } = await client.query(
59
+ `SELECT a.attname AS name,
60
+ format_type(a.atttypid, a.atttypmod) AS type,
61
+ a.attnotnull AS not_null,
62
+ pg_get_expr(d.adbin, d.adrelid) AS default_expr,
63
+ -- 's' for a stored generated column. Its value is computed from the
64
+ -- others, so it can neither be given a DEFAULT nor be inserted
65
+ -- into; treating it as an ordinary column crashed the copy outright.
66
+ NULLIF(a.attgenerated, '') AS generated,
67
+ a.attidentity <> '' AS identity,
68
+ -- What can legally go in here, where the type itself says so. An
69
+ -- enum column takes one of a fixed list and nothing else, and a
70
+ -- generated test string is not on that list.
71
+ -- Cast to text[] on purpose. array_agg over enumlabel produces
72
+ -- name[], which node-postgres has no parser for, so it arrives as
73
+ -- the raw string "{new,paid,shipped}" - and the first "label" is
74
+ -- then the character "{". Exactly what pg_policies.roles did.
75
+ (SELECT array_agg(e.enumlabel::text ORDER BY e.enumsortorder)
76
+ FROM pg_enum e WHERE e.enumtypid = t.oid) AS enum_labels,
77
+ -- A domain is a type with a rule attached. The rule cannot be
78
+ -- guessed at, but the type underneath it can be used.
79
+ CASE WHEN t.typtype = 'd' THEN format_type(t.typbasetype, a.atttypmod) END AS base_type,
80
+ t.typcategory = 'A' AS is_array
81
+ FROM pg_attribute a
82
+ JOIN pg_type t ON t.oid = a.atttypid
83
+ LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
84
+ WHERE a.attrelid = format('%I.%I', $1::text, $2::text)::regclass
85
+ AND a.attnum > 0
86
+ AND NOT a.attisdropped
87
+ ORDER BY a.attnum`,
88
+ [schema, table],
89
+ );
90
+ return rows;
91
+ }
92
+
93
+ /** Primary keys, uniques and checks, in Postgres's own words. */
94
+ async function readConstraints(client, schema, table) {
95
+ const { rows } = await client.query(
96
+ `SELECT con.conname AS name,
97
+ pg_get_constraintdef(con.oid) AS definition,
98
+ con.contype AS kind
99
+ FROM pg_constraint con
100
+ WHERE con.conrelid = format('%I.%I', $1::text, $2::text)::regclass
101
+ ORDER BY con.conname`,
102
+ [schema, table],
103
+ );
104
+ return rows;
105
+ }
106
+
107
+ /**
108
+ * Who was granted what on the sequences a table depends on.
109
+ *
110
+ * Without these the copy is not the app, in the one way that matters most.
111
+ * Supabase grants anon USAGE on every sequence in public, so a stranger can
112
+ * insert into a table whose key is a serial. The copy replayed the table
113
+ * grants and not the sequence ones, so every insert the tampering attack
114
+ * tried came back 'permission denied for sequence' - which reads as the
115
+ * attack being beaten. "A stranger can add rows to your table" was never
116
+ * reported on any table with a serial key, which is most tables, and it was
117
+ * silent: it looked exactly like an app that had got it right.
118
+ *
119
+ * Only sequences a column owns. Those are the ones the copy has - measured,
120
+ * not assumed: serial, bigserial and both kinds of identity all come out
121
+ * with the same name in the copy. A sequence standing on its own does not,
122
+ * and nothing inserts into one, so a grant on it changes no verdict.
123
+ */
124
+ async function readSequenceGrants(client, schema) {
125
+ const { rows } = await client.query(
126
+ `SELECT DISTINCT s.relname AS sequence_name,
127
+ CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END AS grantee,
128
+ a.privilege_type
129
+ FROM pg_class s
130
+ JOIN pg_namespace n ON n.oid = s.relnamespace
131
+ JOIN pg_depend d ON d.objid = s.oid
132
+ AND d.classid = 'pg_class'::regclass
133
+ AND d.deptype IN ('a', 'i')
134
+ CROSS JOIN LATERAL aclexplode(s.relacl) a
135
+ WHERE n.nspname = $1 AND s.relkind = 'S'
136
+ AND (CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END)
137
+ IN ('anon', 'authenticated', 'service_role', 'PUBLIC')
138
+ ORDER BY 1, 2, 3`,
139
+ [schema],
140
+ );
141
+ return rows;
142
+ }
143
+
144
+ /**
145
+ * Unique indexes, which are the other half of "can this happen twice".
146
+ *
147
+ * Read separately from constraints because `CREATE UNIQUE INDEX` and
148
+ * `UNIQUE (...)` are different objects in Postgres and real apps use both. An
149
+ * app whose uniqueness lives in an index would arrive at the copy with none of
150
+ * it, and the Collision attack would then report every such app as broken - a
151
+ * false alarm on exactly the apps that got it right.
152
+ *
153
+ * Primary keys and indexes backing a constraint are left out: those come along
154
+ * with the constraint itself, and creating them again is an error.
155
+ */
156
+ async function readIndexes(client, schema) {
157
+ const { rows } = await client.query(
158
+ `SELECT c.relname AS name,
159
+ t.relname AS table_name,
160
+ pg_get_indexdef(i.indexrelid) AS definition
161
+ FROM pg_index i
162
+ JOIN pg_class c ON c.oid = i.indexrelid
163
+ JOIN pg_class t ON t.oid = i.indrelid
164
+ JOIN pg_namespace n ON n.oid = c.relnamespace
165
+ WHERE n.nspname = $1
166
+ AND i.indisunique
167
+ AND NOT i.indisprimary
168
+ AND NOT EXISTS (SELECT 1 FROM pg_constraint con WHERE con.conindid = i.indexrelid)
169
+ ORDER BY t.relname, c.relname`,
170
+ [schema],
171
+ );
172
+ return rows;
173
+ }
174
+
175
+ /**
176
+ * The policies. These are the thing under test, so they are copied verbatim.
177
+ *
178
+ * pg_policies hands back `qual` and `with_check` already rendered as SQL, which
179
+ * is what makes an exact copy possible at all.
180
+ */
181
+ async function readPolicies(client, schema) {
182
+ const { rows } = await client.query(
183
+ `SELECT tablename AS table_name,
184
+ policyname AS name,
185
+ permissive,
186
+ -- Cast on purpose. pg_policies.roles is name[], which
187
+ -- node-postgres has no parser for, so it arrives as the raw
188
+ -- string "{anon,authenticated}" - and code that treats it as a
189
+ -- list gets the characters of that string instead of the roles.
190
+ -- roleList() was written to undo that; casting here means there is
191
+ -- nothing to undo. The third time this exact shape has cost a bug,
192
+ -- and the one the two engines disagreed about on their first run.
193
+ roles::text[] AS roles,
194
+ cmd,
195
+ qual,
196
+ with_check
197
+ FROM pg_policies
198
+ WHERE schemaname = $1
199
+ ORDER BY tablename, policyname`,
200
+ [schema],
201
+ );
202
+ return rows;
203
+ }
204
+
205
+ /**
206
+ * Who was granted what. A policy is irrelevant if the grant is not there.
207
+ *
208
+ * Restricted to ordinary tables on purpose. role_table_grants also lists views
209
+ * and materialised views, and replaying a grant on a view the copy does not
210
+ * contain fails outright - which took down the scan of any app with a view in
211
+ * it, and almost every app has one.
212
+ */
213
+ async function readGrants(client, schema) {
214
+ const { rows } = await client.query(
215
+ `SELECT g.table_name, g.grantee, g.privilege_type
216
+ FROM information_schema.role_table_grants g
217
+ JOIN pg_class c ON c.relname = g.table_name
218
+ JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = g.table_schema
219
+ WHERE g.table_schema = $1
220
+ AND c.relkind = 'r'
221
+ AND g.grantee IN ('anon', 'authenticated', 'service_role', 'PUBLIC')
222
+ ORDER BY g.table_name, g.grantee, g.privilege_type`,
223
+ [schema],
224
+ );
225
+ return rows;
226
+ }
227
+
228
+ /**
229
+ * Views, which are a door of their own.
230
+ *
231
+ * A view runs with its creator's rights unless it says otherwise, so a view
232
+ * over a table that row level security protects hands out every row in that
233
+ * table to anyone allowed to read the view. The policy is intact, the
234
+ * dashboard is green, and the data is gone. `security_invoker` is what turns
235
+ * that off, and it lives in reloptions, so it is copied with the view.
236
+ */
237
+ async function readViews(client, schema) {
238
+ const { rows } = await client.query(
239
+ `SELECT c.relname AS name,
240
+ pg_get_viewdef(c.oid, true) AS definition,
241
+ c.relkind = 'm' AS materialised,
242
+ array_to_string(c.reloptions, ', ') AS options
243
+ FROM pg_class c
244
+ JOIN pg_namespace n ON n.oid = c.relnamespace
245
+ WHERE n.nspname = $1 AND c.relkind IN ('v', 'm')
246
+ ORDER BY c.relname`,
247
+ [schema],
248
+ );
249
+ return rows;
250
+ }
251
+
252
+ /** Views, with the columns they expose, so a finding can name what leaked. */
253
+ async function readViewsWithColumns(client, schema) {
254
+ const views = await readViews(client, schema);
255
+ for (const view of views) {
256
+ view.columns = await readColumns(client, schema, view.name);
257
+ }
258
+ return views;
259
+ }
260
+
261
+ /** Who was granted what on a view. Separate, because views are created later. */
262
+ async function readViewGrants(client, schema) {
263
+ const { rows } = await client.query(
264
+ `SELECT g.table_name, g.grantee, g.privilege_type
265
+ FROM information_schema.role_table_grants g
266
+ JOIN pg_class c ON c.relname = g.table_name
267
+ JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = g.table_schema
268
+ WHERE g.table_schema = $1
269
+ AND c.relkind IN ('v', 'm')
270
+ AND g.grantee IN ('anon', 'authenticated', 'service_role', 'PUBLIC')
271
+ ORDER BY g.table_name, g.grantee, g.privilege_type`,
272
+ [schema],
273
+ );
274
+ return rows;
275
+ }
276
+
277
+ /**
278
+ * The types the app made for itself: enums, and domains.
279
+ *
280
+ * Copied, so that the copy stands on its own. It used to borrow them from the
281
+ * schema it was copied from, which worked right up until a view mentioned one:
282
+ * pg_get_viewdef writes a literal as 'paid'::app.order_status, the rewrite
283
+ * turned app into the copy, and the copy had no such type. Any app with a view
284
+ * over an enum column - which is a great many of them - crashed the scan
285
+ * outright, with a stack trace where the report should have been.
286
+ *
287
+ * Borrowing was quietly wrong anyway. A copy that reaches back into the
288
+ * original for anything is a copy that can change under the attack.
289
+ */
290
+ async function readTypes(client, schema) {
291
+ const { rows } = await client.query(
292
+ `SELECT t.typname AS name,
293
+ t.typtype AS kind,
294
+ CASE WHEN t.typtype = 'e' THEN (
295
+ SELECT array_agg(e.enumlabel::text ORDER BY e.enumsortorder)
296
+ FROM pg_enum e WHERE e.enumtypid = t.oid
297
+ ) END AS labels,
298
+ CASE WHEN t.typtype = 'd'
299
+ THEN format_type(t.typbasetype, t.typtypmod) END AS base_type,
300
+ CASE WHEN t.typtype = 'd' THEN (
301
+ SELECT array_agg(pg_get_constraintdef(c.oid) ORDER BY c.conname)
302
+ FROM pg_constraint c WHERE c.contypid = t.oid
303
+ ) END AS constraints,
304
+ t.typnotnull AS not_null,
305
+ CASE WHEN t.typtype = 'd' THEN t.typdefault END AS default_value
306
+ FROM pg_type t
307
+ JOIN pg_namespace n ON n.oid = t.typnamespace
308
+ WHERE n.nspname = $1
309
+ AND t.typtype IN ('e', 'd')
310
+ -- Enums first. A domain can be built on one, and a domain created
311
+ -- before the enum it stands on is a type that does not exist yet.
312
+ ORDER BY CASE WHEN t.typtype = 'e' THEN 0 ELSE 1 END, t.typname`,
313
+ [schema],
314
+ );
315
+ return rows;
316
+ }
317
+
318
+ /** What a foreign key points at, pulled out of Postgres's own wording. */
319
+ function referenceIn(definition) {
320
+ const match = /FOREIGN KEY\s*\(([^)]+)\)\s*REFERENCES\s+([^\s(]+)\s*\(([^)]+)\)/i.exec(String(definition));
321
+ if (!match) return null;
322
+ const strip = (text) => text.trim().replace(/^"(.*)"$/, '$1');
323
+ const target = match[2].trim().split(/\.(?=(?:[^"]*"[^"]*")*[^"]*$)/).map(strip);
324
+ return {
325
+ schema: target.length > 1 ? target[0] : null,
326
+ table: target[target.length - 1],
327
+ columns: match[3].split(',').map(strip),
328
+ };
329
+ }
330
+
331
+ /**
332
+ * The name a stand-in for an outside table is given inside the copy.
333
+ *
334
+ * `taken` is every name the customer already uses, and the stand-in keeps
335
+ * moving until it clashes with none of them. Identifying our own tables by
336
+ * their prefix alone meant a customer table that happened to start with the
337
+ * same letters was quietly dropped from every attack - a real hole reported as
338
+ * nothing at all, which is the failure this whole program exists to avoid.
339
+ */
340
+ function stubNameFor(refSchema, refTable, taken) {
341
+ const used = new Set(taken || []);
342
+ const base = 'kn_ext__' + refSchema + '__' + refTable;
343
+ let name = base;
344
+ let nth = 2;
345
+ while (used.has(name)) {
346
+ name = base + '__' + nth;
347
+ nth += 1;
348
+ }
349
+ return name;
350
+ }
351
+
352
+ /**
353
+ * Tables outside this schema that its foreign keys point at.
354
+ *
355
+ * Nearly every Supabase app has `references auth.users(id)`, and the copy
356
+ * cannot carry that as written: pointed at the real auth.users, every seeded
357
+ * row becomes a write into the customer's own authentication table. So the
358
+ * shape of the outside table is read - columns and types, never rows - and a
359
+ * stand-in is built inside the copy instead.
360
+ *
361
+ * A target whose shape cannot be read at all is returned with no columns, and
362
+ * the caller treats that as unsupported rather than guessing at it.
363
+ */
364
+ async function readExternalTargets(client, schema, tables) {
365
+ const wanted = new Map();
366
+ for (const table of tables) {
367
+ for (const constraint of table.constraints || []) {
368
+ if (constraint.kind !== 'f') continue;
369
+ const points = referenceIn(constraint.definition);
370
+ if (!points || !points.schema || points.schema === schema) continue;
371
+ const key = points.schema + '.' + points.table;
372
+ if (!wanted.has(key)) {
373
+ wanted.set(key, { schema: points.schema, table: points.table, columns: new Set() });
374
+ }
375
+ points.columns.forEach((column) => wanted.get(key).columns.add(column));
376
+ }
377
+ }
378
+
379
+ const targets = [];
380
+ for (const entry of wanted.values()) {
381
+ let columns = [];
382
+ try {
383
+ const { rows } = await client.query(
384
+ `SELECT a.attname AS name, format_type(a.atttypid, a.atttypmod) AS type
385
+ FROM pg_attribute a
386
+ WHERE a.attrelid = format('%I.%I', $1::text, $2::text)::regclass
387
+ AND a.attname = ANY($3) AND a.attnum > 0 AND NOT a.attisdropped
388
+ ORDER BY a.attnum`,
389
+ [entry.schema, entry.table, Array.from(entry.columns)],
390
+ );
391
+ columns = rows;
392
+ } catch (err) {
393
+ columns = [];
394
+ }
395
+ targets.push({
396
+ schema: entry.schema,
397
+ table: entry.table,
398
+ columns: columns,
399
+ stub: stubNameFor(entry.schema, entry.table, tables.map((t) => t.name)),
400
+ wanted: Array.from(entry.columns),
401
+ });
402
+ }
403
+ return targets;
404
+ }
405
+
406
+ /**
407
+ * Everything needed to rebuild a schema, and everything that could not be.
408
+ *
409
+ * `unsupported` is the important half. A caller that ignores it is attacking a
410
+ * database that is not the customer's.
411
+ */
412
+ async function readSchema(client, schema) {
413
+ const tables = await readTables(client, schema);
414
+ const unsupported = [];
415
+ const built = [];
416
+
417
+ for (const table of tables) {
418
+ const columns = await readColumns(client, schema, table.name);
419
+ const constraints = await readConstraints(client, schema, table.name);
420
+
421
+ built.push({
422
+ name: table.name,
423
+ rlsEnabled: table.rls_enabled,
424
+ rlsForced: table.rls_forced,
425
+ columns: columns,
426
+ constraints: constraints,
427
+ });
428
+ }
429
+
430
+ // A foreign key pointing outside this schema gets a stand-in inside the
431
+ // copy. Quietly dropping it instead would change what seeding is allowed to
432
+ // insert, and pointing it at the real table would make every seeded row a
433
+ // write into the customer's own data.
434
+ const external = await readExternalTargets(client, schema, built);
435
+ for (const target of external) {
436
+ if (target.columns.length !== target.wanted.length) {
437
+ unsupported.push(
438
+ 'a foreign key points at ' + target.schema + '.' + target.table +
439
+ ', and I could not read its shape to stand in for it',
440
+ );
441
+ }
442
+ }
443
+
444
+ return {
445
+ schema: schema,
446
+ tables: built,
447
+ policies: await readPolicies(client, schema),
448
+ grants: await readGrants(client, schema),
449
+ sequenceGrants: await readSequenceGrants(client, schema),
450
+ types: await readTypes(client, schema),
451
+ indexes: await readIndexes(client, schema),
452
+ views: await readViewsWithColumns(client, schema),
453
+ viewGrants: await readViewGrants(client, schema),
454
+ external: external,
455
+ unsupported: unsupported,
456
+ };
457
+ }
458
+
459
+ /* --------------------------------------------------------------------------
460
+ Rebuilding.
461
+ -------------------------------------------------------------------------- */
462
+
463
+ function quote(name) {
464
+ return '"' + String(name).split('"').join('""') + '"';
465
+ }
466
+
467
+ /**
468
+ * The roles a policy applies to, as a list.
469
+ *
470
+ * pg_policies hands this back as the raw Postgres array literal - the string
471
+ * "{anon,authenticated}", not an array - because node-postgres has no parser
472
+ * registered for name[]. Measured, not assumed. Treating it as an array gives
473
+ * either a crash or, worse, a policy created for the wrong roles.
474
+ */
475
+ function roleList(roles) {
476
+ if (Array.isArray(roles)) return roles;
477
+ const raw = String(roles === null || roles === undefined ? '' : roles).trim();
478
+ if (!raw || raw === '{}') return [];
479
+ const inner = raw.startsWith('{') && raw.endsWith('}') ? raw.slice(1, -1) : raw;
480
+ return inner
481
+ .split(',')
482
+ .map((part) => part.trim().replace(/^"(.*)"$/, '$1'))
483
+ .filter(Boolean);
484
+ }
485
+
486
+ /**
487
+ * Points anything schema-qualified at the copy instead of the original.
488
+ *
489
+ * Both spellings, and that is the whole point of this existing. Postgres
490
+ * writes a name unquoted when it does not need quoting, so a foreign key came
491
+ * back as "REFERENCES app.profiles(id)" while only the quoted form was being
492
+ * rewritten - and the copy was created holding a live reference into the
493
+ * customer's real schema. Every insert into the copy would then have been
494
+ * checked against the customer's real table, which is the one thing this is
495
+ * built never to touch.
496
+ */
497
+ function rewriteSchemaRefs(expr, fromSchema, toSchema) {
498
+ if (!expr) return null;
499
+ return String(expr).split(quote(fromSchema) + '.').join(quote(toSchema) + '.').split(fromSchema + '.').join(toSchema + '.');
500
+ }
501
+
502
+ /**
503
+ * Points a foreign key at the stand-in instead of at the real outside table.
504
+ *
505
+ * Both spellings again, for the same reason the schema rewrite handles both:
506
+ * Postgres writes a name unquoted whenever it does not have to quote it, and
507
+ * missing one spelling leaves the copy holding a live reference into the
508
+ * customer's database.
509
+ */
510
+ function rewriteExternalRefs(expr, external, toSchema) {
511
+ let text = String(expr);
512
+ for (const target of external || []) {
513
+ const stub = quote(toSchema) + '.' + quote(target.stub);
514
+ text = text
515
+ .split(quote(target.schema) + '.' + quote(target.table))
516
+ .join(stub)
517
+ .split(target.schema + '.' + target.table)
518
+ .join(stub);
519
+ }
520
+ return text;
521
+ }
522
+
523
+ /** Two people who do not exist, used wherever a stand-in row is needed. */
524
+ const IDENTITIES = ['11111111-1111-4111-8111-111111111111', '22222222-2222-4222-8222-222222222222'];
525
+
526
+ /** Something of the right type to put in a stand-in row. */
527
+ function stubValue(type, nth) {
528
+ const kind = String(type).toLowerCase();
529
+ if (kind === 'uuid') return "'" + IDENTITIES[nth] + "'";
530
+ if (/^(integer|bigint|smallint|numeric|decimal|real|double)/.test(kind)) return String(nth + 1);
531
+ if (/^bool/.test(kind)) return nth === 0 ? 'true' : 'false';
532
+ if (/^(timestamp|date)/.test(kind)) return 'now()';
533
+ return "'kryptheon-" + (nth + 1) + "'";
534
+ }
535
+
536
+ /** Which of these roles this database actually has. */
537
+ async function existingRoles(client, wanted) {
538
+ const { rows } = await client.query('SELECT rolname FROM pg_roles WHERE rolname = ANY($1)', [wanted]);
539
+ return rows.map((row) => row.rolname);
540
+ }
541
+
542
+ /**
543
+ * Nothing may be written outside the copy. Ever.
544
+ *
545
+ * The product is sold on one sentence - we never touch your live app - and
546
+ * this is the line that keeps it true. It is a structural guard rather than a
547
+ * careful habit, because the two statements that broke the promise were
548
+ * written carefully and sat there for weeks: every check built its own
549
+ * auth.uid() first, so replacing the customer's looked exactly like doing
550
+ * nothing.
551
+ *
552
+ * Every statement has to name the copy schema, quoted or not. A statement that
553
+ * does not is not run, and the scan stops rather than guessing.
554
+ */
555
+ function mustStayInside(statements, target) {
556
+ const bare = new RegExp('(^|[^A-Za-z0-9_"])' + target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\.');
557
+ for (const statement of statements) {
558
+ if (statement.includes(quote(target)) || bare.test(statement)) continue;
559
+ throw new Error(
560
+ 'refusing to run a statement that does not stay inside the copy ' + target + ': ' + statement,
561
+ );
562
+ }
563
+ return statements;
564
+ }
565
+
566
+ /**
567
+ * Builds the schema again, in a database we own.
568
+ *
569
+ * Order matters: tables, then constraints, then grants, then policies. A policy
570
+ * cannot be created before the table it guards, and a grant given after a
571
+ * policy would widen access the original did not have.
572
+ */
573
+ async function writeSchema(client, plan, target) {
574
+ const statements = ['CREATE SCHEMA ' + quote(target)];
575
+
576
+ // The app's own types first: a column, a check, a view or a cast can all
577
+ // name one, and nothing that mentions a type can be created before it.
578
+ for (const made of plan.types || []) {
579
+ if (made.kind === 'e') {
580
+ statements.push(
581
+ 'CREATE TYPE ' + quote(target) + '.' + quote(made.name) + ' AS ENUM (' +
582
+ (made.labels || []).map((label) => "'" + String(label).split("'").join("''") + "'").join(', ') + ')',
583
+ );
584
+ } else {
585
+ const here = (text) => rewriteSchemaRefs(text, plan.schema, target);
586
+ // A domain can stand on another of the app's own types, and its rule can
587
+ // name one too, so both go through the same rewrite a column does.
588
+ const parts = ['CREATE DOMAIN ' + quote(target) + '.' + quote(made.name) + ' AS ' + here(made.base_type)];
589
+ if (made.default_value) parts.push('DEFAULT ' + here(made.default_value));
590
+ if (made.not_null) parts.push('NOT NULL');
591
+ for (const rule of made.constraints || []) parts.push(here(rule));
592
+ statements.push(parts.join(' '));
593
+ }
594
+ }
595
+
596
+ for (const table of plan.tables) {
597
+ const columns = table.columns.map((column) => {
598
+ // The type is rewritten like everything else now that the copy has its
599
+ // own. Left alone, the copy leaned on the original for them.
600
+ const parts = [quote(column.name), rewriteSchemaRefs(column.type, plan.schema, target)];
601
+ const fallback = rewriteSchemaRefs(column.default_expr, plan.schema, target);
602
+ if (column.generated) {
603
+ // A stored generated column carries its expression in default_expr, and
604
+ // replaying that as a DEFAULT is a syntax error - "cannot use column
605
+ // reference in DEFAULT expression" - which took the whole scan down.
606
+ parts.push('GENERATED ALWAYS AS (' + fallback + ') STORED');
607
+ } else if (column.identity) {
608
+ parts.push('GENERATED ALWAYS AS IDENTITY');
609
+ } else if (fallback) {
610
+ parts.push('DEFAULT ' + fallback);
611
+ }
612
+ if (column.not_null && !column.generated && !column.identity) parts.push('NOT NULL');
613
+ return parts.join(' ');
614
+ });
615
+
616
+ // Sequences first, or a serial column's default has nothing to point at.
617
+ for (const column of table.columns) {
618
+ const match = /nextval\('([^']+)'/.exec(column.default_expr || '');
619
+ if (!match) continue;
620
+ const bare = match[1].split('.').pop().split('"').join('');
621
+ statements.push('CREATE SEQUENCE IF NOT EXISTS ' + quote(target) + '.' + quote(bare));
622
+ }
623
+
624
+ statements.push(
625
+ 'CREATE TABLE ' + quote(target) + '.' + quote(table.name) + ' (' + columns.join(', ') + ')',
626
+ );
627
+
628
+ // And the sequence belongs to its column, the way serial makes it.
629
+ //
630
+ // Not tidiness. A sequence a column owns is linked to it in pg_depend,
631
+ // and that link is how the grants on it are found again - so a copy
632
+ // whose sequences stand loose reads back as having no sequence grants
633
+ // at all, however many were replayed onto it. It also means the
634
+ // sequence goes when the copy's table goes, which is what the original
635
+ // does.
636
+ for (const column of table.columns) {
637
+ const match = /nextval\('([^']+)'/.exec(column.default_expr || '');
638
+ if (!match) continue;
639
+ const bare = match[1].split('.').pop().split('"').join('');
640
+ statements.push(
641
+ 'ALTER SEQUENCE ' + quote(target) + '.' + quote(bare) + ' OWNED BY ' +
642
+ quote(target) + '.' + quote(table.name) + '.' + quote(column.name),
643
+ );
644
+ }
645
+ }
646
+
647
+ // Stand-ins for the tables outside this schema that its foreign keys point
648
+ // at, with two rows already in them so seeding has something to reference.
649
+ // Built before the constraints, because a foreign key cannot be added to a
650
+ // table that is not there yet.
651
+ for (const outside of plan.external || []) {
652
+ const stub = quote(target) + '.' + quote(outside.stub);
653
+ const columns = outside.columns.map((column) => quote(column.name) + ' ' + column.type + ' NOT NULL');
654
+ const keyed = outside.columns.map((column) => quote(column.name)).join(', ');
655
+ statements.push('CREATE TABLE ' + stub + ' (' + columns.join(', ') + ', PRIMARY KEY (' + keyed + '))');
656
+ for (let nth = 0; nth < IDENTITIES.length; nth++) {
657
+ const values = outside.columns.map((column) => stubValue(column.type, nth));
658
+ statements.push('INSERT INTO ' + stub + ' VALUES (' + values.join(', ') + ')');
659
+ }
660
+ }
661
+
662
+ // Keys and uniques across every table first, then the foreign keys.
663
+ // Constraints used to be added table by table, so a foreign key on `orders`
664
+ // was created before the primary key on `profiles` existed and Postgres
665
+ // refused it: "no unique constraint matching given keys". A foreign key can
666
+ // only be added once the thing it points at is already unique.
667
+ for (const wantForeign of [false, true]) {
668
+ for (const table of plan.tables) {
669
+ for (const constraint of table.constraints) {
670
+ const isForeign = constraint.kind === 'f';
671
+ if (isForeign !== wantForeign) continue;
672
+ const definition = rewriteExternalRefs(
673
+ rewriteSchemaRefs(constraint.definition, plan.schema, target),
674
+ plan.external,
675
+ target,
676
+ );
677
+ statements.push(
678
+ 'ALTER TABLE ' + quote(target) + '.' + quote(table.name) +
679
+ ' ADD CONSTRAINT ' + quote(constraint.name) + ' ' + definition,
680
+ );
681
+ }
682
+ }
683
+ }
684
+
685
+ // Unique indexes, once every table exists. The schema name inside the
686
+ // definition is rewritten for the same reason a foreign key's was: Postgres
687
+ // writes it unquoted, and every single index definition carries it. Replayed
688
+ // as-is, the copy would build its indexes on the customer's real tables.
689
+ for (const index of plan.indexes || []) {
690
+ statements.push(rewriteSchemaRefs(index.definition, plan.schema, target));
691
+ }
692
+
693
+ // Views last, because they read from the tables above. Copied rather than
694
+ // skipped because a view is a way into a table: it runs with its creator
695
+ // rights unless it says security_invoker, so a view over a protected table
696
+ // hands out every row in it. Leaving views out meant never looking at that
697
+ // door at all.
698
+ for (const view of plan.views || []) {
699
+ const body = rewriteExternalRefs(rewriteSchemaRefs(view.definition, plan.schema, target), plan.external, target);
700
+ const options = view.options ? ' WITH (' + view.options + ')' : '';
701
+ statements.push(
702
+ 'CREATE ' + (view.materialised ? 'MATERIALIZED VIEW ' : 'VIEW ') +
703
+ quote(target) + '.' + quote(view.name) + options + ' AS ' + body,
704
+ );
705
+ }
706
+
707
+ for (const grant of plan.viewGrants || []) {
708
+ const who = grant.grantee === 'PUBLIC' ? 'PUBLIC' : quote(grant.grantee);
709
+ statements.push(
710
+ 'GRANT ' + grant.privilege_type + ' ON ' + quote(target) + '.' + quote(grant.table_name) + ' TO ' + who,
711
+ );
712
+ }
713
+
714
+ // The copy needs the roles PostgREST switches into to be able to reach it.
715
+ // Granted on the copy's own schema and nowhere else.
716
+ //
717
+ // What used to be here, and must never come back: CREATE SCHEMA auth,
718
+ // CREATE OR REPLACE FUNCTION auth.uid(), and GRANT USAGE ON SCHEMA auth.
719
+ // Those ran against the customer's live database. The first overwrote their
720
+ // own authentication function; the third opened a schema they may have
721
+ // deliberately closed. The policies reference auth.uid() and that is fine -
722
+ // calling their function is a read, and a read is all we are ever allowed.
723
+ // If it is missing, their app does not work either, and the copy failing to
724
+ // build says so honestly instead of papering over it.
725
+ for (const role of await existingRoles(client, ['anon', 'authenticated'])) {
726
+ statements.push('GRANT USAGE ON SCHEMA ' + quote(target) + ' TO ' + quote(role));
727
+ }
728
+
729
+ for (const grant of plan.grants) {
730
+ const who = grant.grantee === 'PUBLIC' ? 'PUBLIC' : quote(grant.grantee);
731
+ statements.push(
732
+ 'GRANT ' + grant.privilege_type + ' ON ' + quote(target) + '.' + quote(grant.table_name) + ' TO ' + who,
733
+ );
734
+ }
735
+
736
+ // The sequences, on the same terms as the tables. A table grant without
737
+ // the sequence grant that goes with it is a copy nobody can insert into.
738
+ for (const grant of plan.sequenceGrants || []) {
739
+ const who = grant.grantee === 'PUBLIC' ? 'PUBLIC' : quote(grant.grantee);
740
+ statements.push(
741
+ 'GRANT ' + grant.privilege_type + ' ON SEQUENCE ' + quote(target) + '.' +
742
+ quote(grant.sequence_name) + ' TO ' + who,
743
+ );
744
+ }
745
+
746
+ for (const table of plan.tables) {
747
+ if (table.rlsEnabled) {
748
+ statements.push('ALTER TABLE ' + quote(target) + '.' + quote(table.name) + ' ENABLE ROW LEVEL SECURITY');
749
+ }
750
+ if (table.rlsForced) {
751
+ statements.push('ALTER TABLE ' + quote(target) + '.' + quote(table.name) + ' FORCE ROW LEVEL SECURITY');
752
+ }
753
+ }
754
+
755
+ for (const policy of plan.policies) {
756
+ const roles = roleList(policy.roles).join(', ') || 'PUBLIC';
757
+ const parts = [
758
+ 'CREATE POLICY ' + quote(policy.name),
759
+ 'ON ' + quote(target) + '.' + quote(policy.table_name),
760
+ 'AS ' + (policy.permissive === 'PERMISSIVE' ? 'PERMISSIVE' : 'RESTRICTIVE'),
761
+ 'FOR ' + policy.cmd,
762
+ 'TO ' + roles,
763
+ ];
764
+ if (policy.qual) parts.push('USING (' + policy.qual + ')');
765
+ if (policy.with_check) parts.push('WITH CHECK (' + policy.with_check + ')');
766
+ statements.push(parts.join(' '));
767
+ }
768
+
769
+ mustStayInside(statements, target);
770
+ for (const statement of statements) {
771
+ await client.query(statement);
772
+ }
773
+ return statements;
774
+ }
775
+
776
+ /* --------------------------------------------------------------------------
777
+ Checking the copy is the original.
778
+ -------------------------------------------------------------------------- */
779
+
780
+ /** A piece of SQL with its own schema name taken off, quoted or not. */
781
+ function withoutSchema(text, plan) {
782
+ return String(text == null ? '' : text)
783
+ .split(quote(plan.schema) + '.').join('')
784
+ .split(plan.schema + '.').join('');
785
+ }
786
+
787
+ /**
788
+ * Where a copy differs from what it was copied from.
789
+ *
790
+ * Anything here means the verdicts that follow are about the wrong database,
791
+ * so this returning empty is a precondition for attacking, not a nicety.
792
+ */
793
+ function diffSchemas(source, copy) {
794
+ const differences = [];
795
+
796
+ // The stand-ins exist only in the copy, by design. Comparing them against
797
+ // an original that never had them would report every Supabase app as a
798
+ // copy that came out wrong.
799
+ const standIns = new Set((source.external || []).map((e) => e.stub));
800
+ const mine = (list) => list.filter((t) => !standIns.has(t.name));
801
+ const named = (list) => mine(list).map((t) => t.name).sort().join(', ');
802
+ if (named(source.tables) !== named(copy.tables)) {
803
+ differences.push('tables differ: ' + named(source.tables) + ' vs ' + named(copy.tables));
804
+ }
805
+
806
+ for (const table of source.tables) {
807
+ const mirror = copy.tables.find((t) => t.name === table.name);
808
+ if (!mirror) continue;
809
+
810
+ if (table.rlsEnabled !== mirror.rlsEnabled) {
811
+ differences.push(
812
+ table.name + ': row level security is ' + (table.rlsEnabled ? 'on' : 'off') +
813
+ ' but the copy has it ' + (mirror.rlsEnabled ? 'on' : 'off'),
814
+ );
815
+ }
816
+ if (table.rlsForced !== mirror.rlsForced) {
817
+ differences.push(table.name + ': forced row level security does not match');
818
+ }
819
+
820
+ // The schema name comes off the type first. A column of the app's own enum
821
+ // reads as app.order_status in the original and as <copy>.order_status in
822
+ // the copy - the same type, built twice, and calling that a difference
823
+ // would stop every scan of an app that has one.
824
+ const shape = (plan, t) =>
825
+ t.columns.map((c) => c.name + ' ' + withoutSchema(c.type, plan) + (c.not_null ? ' NOT NULL' : '')).join(' | ');
826
+ if (shape(source, table) !== shape(copy, mirror)) {
827
+ differences.push(table.name + ' columns differ:\n ' + shape(source, table) + '\n ' + shape(copy, mirror));
828
+ }
829
+ }
830
+
831
+ // The app's own types. An enum that arrived with a label missing narrows
832
+ // what the attacks are able to insert, and nothing else here would notice.
833
+ const typeText = (plan) =>
834
+ (plan.types || [])
835
+ .map((made) =>
836
+ made.name + ' ' + made.kind + ' ' + (made.labels || []).join(',') + ' ' +
837
+ withoutSchema(made.base_type, plan) + ' ' +
838
+ (made.constraints || []).map((rule) => withoutSchema(rule, plan)).join(' '))
839
+ .sort();
840
+ const sourceTypes = typeText(source);
841
+ const copyTypes = typeText(copy);
842
+ for (const made of sourceTypes) {
843
+ if (!copyTypes.includes(made)) differences.push('a type did not come across whole: ' + made);
844
+ }
845
+
846
+ // The sequence grants, for the same reason they are copied at all: without
847
+ // them nothing can insert, and every write attack reads as the app
848
+ // defending itself.
849
+ const sequenceText = (plan) =>
850
+ (plan.sequenceGrants || [])
851
+ .map((g) => g.sequence_name + ' ' + g.grantee + ' ' + g.privilege_type)
852
+ .sort();
853
+ const sourceSequences = sequenceText(source);
854
+ const copySequences = sequenceText(copy);
855
+ for (const one of sourceSequences) {
856
+ if (!copySequences.includes(one)) {
857
+ differences.push('a sequence grant did not come across: ' + one);
858
+ }
859
+ }
860
+
861
+ // Uniqueness decides whether the Collision attack has anything to report, so
862
+ // a unique index that failed to come across has to be caught here rather
863
+ // than turn into a confident finding about a table that was actually fine.
864
+ // The schema name is stripped before comparing, since it differs by design.
865
+ const indexText = (plan) =>
866
+ (plan.indexes || [])
867
+ .map((index) => withoutSchema(index.definition, plan))
868
+ .sort();
869
+ const sourceIndexes = indexText(source);
870
+ const copyIndexes = indexText(copy);
871
+ if (sourceIndexes.length !== copyIndexes.length) {
872
+ differences.push(
873
+ 'the copy has ' + copyIndexes.length + ' unique indexes, the original has ' + sourceIndexes.length,
874
+ );
875
+ }
876
+ for (let i = 0; i < Math.max(sourceIndexes.length, copyIndexes.length); i++) {
877
+ if (sourceIndexes[i] !== copyIndexes[i]) {
878
+ differences.push('a unique index came across changed:\n ' + sourceIndexes[i] + '\n ' + copyIndexes[i]);
879
+ }
880
+ }
881
+
882
+ // The policies matter most, so they are compared word for word.
883
+ const asText = (list, schema) =>
884
+ list
885
+ .map((p) =>
886
+ [p.table_name, p.name, p.permissive, roleList(p.roles).join('+'), p.cmd, p.qual, p.with_check]
887
+ .map((x) => String(x === null || x === undefined ? '' : x))
888
+ .join(' :: '),
889
+ )
890
+ .sort();
891
+
892
+ const sourcePolicies = asText(source.policies);
893
+ const copyPolicies = asText(copy.policies);
894
+ if (sourcePolicies.length !== copyPolicies.length) {
895
+ differences.push('the copy has ' + copyPolicies.length + ' policies, the original has ' + sourcePolicies.length);
896
+ }
897
+ for (let i = 0; i < Math.max(sourcePolicies.length, copyPolicies.length); i++) {
898
+ if (sourcePolicies[i] !== copyPolicies[i]) {
899
+ differences.push('a policy came across changed:\n ' + sourcePolicies[i] + '\n ' + copyPolicies[i]);
900
+ }
901
+ }
902
+
903
+ return differences;
904
+ }
905
+
906
+ module.exports = {
907
+ readSchema: readSchema,
908
+ writeSchema: writeSchema,
909
+ diffSchemas: diffSchemas,
910
+ readPolicies: readPolicies,
911
+ readTypes: readTypes,
912
+ readSequenceGrants: readSequenceGrants,
913
+ readIndexes: readIndexes,
914
+ readViews: readViews,
915
+ readExternalTargets: readExternalTargets,
916
+ referenceIn: referenceIn,
917
+ rewriteSchemaRefs: rewriteSchemaRefs,
918
+ stubNameFor: stubNameFor,
919
+ IDENTITIES: IDENTITIES,
920
+ quote: quote,
921
+ roleList: roleList,
922
+ };