kryptheon-night 0.1.39 → 0.1.40

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.
Files changed (9) hide show
  1. package/attack.js +1028 -1025
  2. package/bin/kryptheon-night.js +736 -725
  3. package/engine.sql +4657 -4430
  4. package/finding.js +1715 -1612
  5. package/intro.js +311 -303
  6. package/package.json +98 -98
  7. package/scan.js +1330 -1311
  8. package/schema.js +1588 -1555
  9. package/tamper.js +949 -772
package/attack.js CHANGED
@@ -1,1025 +1,1028 @@
1
- // The impersonation attack.
2
- //
3
- // Sign in as one person, ask for another person's data, and see what comes
4
- // back. This is the attack a recorded browser flow can never find, because the
5
- // frontend never even tries to ask for somebody else's rows - it only ever
6
- // requests what the signed-in person is supposed to have.
7
- //
8
- // It runs the way a real request runs: become the role PostgREST becomes, put
9
- // the caller's identity where PostgREST puts it, then read. Verified against a
10
- // real database in probe-rls.js before any of this was written.
11
- //
12
- // Two fake people are seeded and nothing else is ever inserted, so a finding is
13
- // always about rows this tool created. No real customer is involved at any
14
- // point, which is the promise the whole product is sold on.
15
-
16
- const { quote } = require('./schema.js');
17
-
18
- const USER_A = '11111111-1111-4111-8111-111111111111';
19
- const USER_B = '22222222-2222-4222-8222-222222222222';
20
-
21
- // Two more who own nothing at all. The seeded pair already hold a row each,
22
- // so an attack that inserts under their name collides with the row the seeder
23
- // put there - on a table keyed by the person, that is a primary key clash, and
24
- // it was being read as the app refusing the attack. These are for any attack
25
- // that has to add a row of its own.
26
- const USER_C = '55555555-5555-4555-8555-555555555555';
27
- const USER_D = '66666666-6666-4666-8666-666666666666';
28
-
29
- // The names an owner column is actually given, in the order they are worth
30
- // believing. `id` is last and only counts on a table that looks like a profile,
31
- // where the row id IS the person.
32
- const OWNER_NAMES = ['user_id', 'owner_id', 'owner', 'profile_id', 'account_id', 'created_by', 'author_id'];
33
-
34
- // A key to Supabase's auth.users as the app wrote it. In the copy it points
35
- // at a stand-in named kn_ext__auth__users, which the table-of-people rule
36
- // below already recognises; this one is for a table itself called users.
37
- const TO_AUTH_USERS = /REFERENCES\s+"?auth"?\s*\.\s*"?users"?\s*\(/i;
38
- // A table that looks like it holds people, keyed by the person.
39
- const PERSON_TABLE = /(profile|user|account|member)/i;
40
-
41
- /**
42
- * The single-column keys from a uuid column, with what they point at. Read
43
- * from the constraint wording itself, because a key to auth.users and a key
44
- * to public.users are different things and the schema is part of the words.
45
- */
46
- function personKeys(table, uuids) {
47
- const byName = new Set(uuids.map((c) => c.name));
48
- const keys = [];
49
- for (const constraint of table.constraints || []) {
50
- if (constraint.kind !== 'f') continue;
51
- const definition = String(constraint.definition || '');
52
- const match = /FOREIGN KEY \(([^)]+)\) REFERENCES ([^(]+)\(([^)]+)\)/i.exec(definition);
53
- if (!match) continue;
54
- const columns = match[1].split(',').map((t) => t.trim().split('"').join(''));
55
- const refColumns = match[3].split(',').map((t) => t.trim().split('"').join(''));
56
- if (columns.length !== 1 || !byName.has(columns[0])) continue;
57
- keys.push({
58
- column: columns[0],
59
- toAuth: TO_AUTH_USERS.test(definition),
60
- refTable: match[2].split('.').pop().trim().split('"').join(''),
61
- refColumn: refColumns[0],
62
- });
63
- }
64
- return keys;
65
- }
66
-
67
- /** The column that says who a row belongs to, or null if nothing does. */
68
- function ownerColumn(table) {
69
- const uuids = table.columns.filter((c) => c.type === 'uuid');
70
- for (const name of OWNER_NAMES) {
71
- const found = uuids.find((c) => c.name === name);
72
- if (found) return found.name;
73
- }
74
- // Not named like an owner, but pointing at a person. Found on a blind test:
75
- // appointments.provider_id -> profiles.id -> auth.users. Judged by name
76
- // alone it had no owner, so "one provider reads another's patients" - a
77
- // rule written USING (true) - was never tried, and the worst hole in the
78
- // app went unreported. `id` first: a table keyed by the person is theirs.
79
- // Two people on one row - sender_id and recipient_id - and neither is
80
- // named: whose row it is cannot be told, and guessing would report one
81
- // person reading a message sent to them as a break. So only one, or none.
82
- const people = personKeys(table, uuids).filter((k) => k.toAuth ||
83
- (k.refColumn === 'id' && k.refTable !== table.name && PERSON_TABLE.test(k.refTable)));
84
- const columns = Array.from(new Set(people.map((k) => k.column)));
85
- if (columns.includes('id')) return 'id';
86
- if (columns.length === 1) return columns[0];
87
- // A profiles table keyed by the person themselves.
88
- if (/profile|user|account|member/i.test(table.name)) {
89
- const id = uuids.find((c) => c.name === 'id');
90
- if (id) return id.name;
91
- }
92
- return null;
93
- }
94
-
95
- /** Keeps a generated value inside a declared width like varchar(20). */
96
- function fitTo(text, type) {
97
- const match = /^[a-z ]*\((\d+)\)$/.exec(String(type).trim());
98
- if (!match) return text;
99
- const limit = Number(match[1]);
100
- return text.length > limit ? text.slice(0, limit) : text;
101
- }
102
-
103
- /**
104
- * Something valid to put in a column, so a row can exist at all.
105
- *
106
- * `distinct` is what keeps two seeded rows from being identical. Without it
107
- * every row carried the same text, so a table with a unique email column
108
- * refused the second insert and the whole table was reported as "not checked"
109
- * - a well-built app treated as an unknown one. The tag goes at the front
110
- * because a narrow varchar truncates the end, and two rows truncated to the
111
- * same string is that bug all over again.
112
- */
113
- function valueFor(column, owner, distinct, attempt) {
114
- // A domain is somebody's own type with a rule bolted on. The rule cannot be
115
- // guessed at from here, but the type underneath it can be filled in.
116
- const type = String(column.base_type || column.type).toLowerCase();
117
- const tag = distinct === undefined || distinct === null ? '' : String(distinct);
118
- const step = Number(tag) || 0;
119
-
120
- // An enum accepts one of a fixed list and nothing else. Every generated
121
- // string was rejected, and the table went down as "not checked".
122
- //
123
- // Insisting on a real array rather than accepting anything with a length:
124
- // the labels arrived once as the string "{new,paid,shipped}", whose first
125
- // element is the character "{", and that is a value the database rejects
126
- // just as firmly while looking like the code is working.
127
- if (Array.isArray(column.enum_labels) && column.enum_labels.length) return column.enum_labels[0];
128
- // Anything at all is allowed in an empty array, whatever the element type.
129
- if (column.is_array || /\[\]$/.test(type)) return '{}';
130
-
131
- if (type === 'uuid') return owner;
132
- if (/^(integer|bigint|smallint|numeric|decimal|real|double|money)/.test(type)) return 1 + step;
133
- if (/^bool/.test(type)) return true;
134
- if (/^(timestamp|date)/.test(type)) return new Date().toISOString();
135
- if (/^time/.test(type)) return '12:00:00';
136
- if (/^interval/.test(type)) return '1 day';
137
- if (/^json/.test(type)) return '{}';
138
- if (/^(inet|cidr)/.test(type)) return '192.0.2.' + (1 + step);
139
- if (/^macaddr8/.test(type)) return '08:00:2b:01:02:03:04:0' + (5 + step);
140
- if (/^macaddr/.test(type)) return '08:00:2b:01:02:0' + (3 + step);
141
- if (/^(tsvector|tsquery)/.test(type)) return 'kryptheon';
142
- if (/^bytea/.test(type)) return Buffer.from('kryptheon');
143
- if (/^xml/.test(type)) return '<kryptheon/>';
144
- if (/^bit/.test(type)) return '0';
145
- if (/^(point|line|lseg|box|path|polygon|circle)/.test(type)) return '(0,0)';
146
-
147
- // Text is where the rules live that cannot be read: a domain that insists on
148
- // an @, a CHECK on a length, a regex for a product code. Rather than pretend
149
- // to understand them, the seeder works down a short ladder of shapes and
150
- // keeps whichever one the database accepts.
151
- const shapes = [
152
- tag ? tag + ' kryptheon test' : 'kryptheon test',
153
- 'kryptheon' + (tag || '') + '@example.com',
154
- 'KN' + (tag || '1'),
155
- String(step + 1),
156
- 'https://example.com/kryptheon',
157
- ];
158
- return fitTo(shapes[Math.min(Math.max(attempt || 0, 0), shapes.length - 1)], type);
159
- }
160
-
161
- /**
162
- * Values a CHECK constraint will actually accept for one column.
163
- *
164
- * `status text CHECK (status IN ('open','closed'))` is one of the most common
165
- * things anyone writes, and Postgres stores it as
166
- * `CHECK ((status = ANY (ARRAY['open'::text, 'closed'::text])))`. Reading the
167
- * literals back out turns a table that could never be seeded into one that can.
168
- *
169
- * Only this one shape is understood, deliberately. A CHECK can contain
170
- * anything, and pretending to satisfy an arbitrary one would mean inventing
171
- * rows that the app itself would reject.
172
- */
173
- function allowedByCheck(table, columnName) {
174
- const found = [];
175
- const escaped = String(columnName).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
176
- // Both spellings of the same rule. On a text column Postgres writes
177
- // state = ANY (ARRAY['open'::text, ...])
178
- // and on a varchar column it writes
179
- // (state)::text = ANY ((ARRAY['open'::character varying, ...])::text[])
180
- // which the first pattern could not cross: it stopped at the bracket that
181
- // closes the cast. A varchar column with an IN list is an ordinary thing to
182
- // write, and every table with one was seeded with an invented value, refused
183
- // by its own constraint, and reported as a table that could not be checked.
184
- //
185
- // What may sit between the column and the = is spelled out rather than left
186
- // to a negated class: anything looser reaches across the next AND and hands
187
- // one column's allowed values to another.
188
- const anyOf = new RegExp(
189
- '\\b' + escaped + '\\b\\)?(?:::[a-z ]+)?\\s*=\\s*ANY\\s*\\(+\\s*ARRAY\\[(.*?)\\]',
190
- 'i',
191
- );
192
- for (const constraint of table.constraints || []) {
193
- if (constraint.kind !== 'c') continue;
194
- const match = anyOf.exec(String(constraint.definition));
195
- if (!match) continue;
196
- // Picked out one at a time rather than split on commas, which came apart
197
- // in the middle of any value that had a comma in it.
198
- const literals = /'((?:[^']|'')*)'/g;
199
- let literal;
200
- while ((literal = literals.exec(match[1])) !== null) {
201
- found.push(literal[1].split("''").join("'"));
202
- }
203
- }
204
- return found;
205
- }
206
-
207
- /**
208
- * The CHECK constraints on a table that name this column, as the expression
209
- * inside CHECK (...). A NOT VALID one still applies to every new row.
210
- */
211
- function checksOn(table, columnName) {
212
- const escaped = String(columnName).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
213
- const named = new RegExp('(^|[^A-Za-z0-9_$"])("' + escaped.split('"').join('""') + '"|' + escaped + ')($|[^A-Za-z0-9_$"])');
214
- const found = [];
215
- for (const constraint of table.constraints || []) {
216
- if (constraint.kind !== 'c') continue;
217
- const definition = String(constraint.definition || '');
218
- if (!named.test(definition)) continue;
219
- found.push(definition.replace(/^CHECK\s*/i, '').replace(/\s+NOT VALID\s*$/i, ''));
220
- }
221
- return found;
222
- }
223
-
224
- const NUMERIC_KIND = /^(integer|bigint|smallint|numeric|decimal|real|double)/;
225
- const TEXT_KIND = /^(text|character|varchar|citext)/;
226
-
227
- /**
228
- * Values to offer a column whose CHECK could not be read, best first.
229
- *
230
- * Found on a real app (a clinic's, 2026-10-11): patient_waitlist and
231
- * whatsapp_contacts each had a phone number column with a CHECK on its
232
- * format, compliance_requests a type column with a CHECK no reading here
233
- * understood - and none of the made-up values fitted, so three tables, and a
234
- * fourth that pointed at one of them, were never tested. Nothing here reads
235
- * the rule. Each value is put to the rule itself, in the database
236
- * (fittingValue), and the first it accepts is used.
237
- *
238
- * Mirrored by __KN__.check_candidates in engine.sql, value for value and in
239
- * the same order: both engines must seed the same rows.
240
- */
241
- function checkCandidates(column, definitions, distinct) {
242
- const kind = String(column.base_type || column.type).toLowerCase();
243
- const tag = distinct === undefined || distinct === null ? '' : String(distinct);
244
- const step = Number(tag) || 0;
245
- const out = [];
246
- const add = (value) => { if (out.indexOf(value) === -1) out.push(value); };
247
- if (NUMERIC_KIND.test(kind)) {
248
- [1 + step, 18 + step, 100 + step, 1000 + step, 0, 5].forEach((n) => add(String(n)));
249
- for (const definition of definitions) {
250
- const numbers = definition.match(/-?\d+(?:\.\d+)?/g) || [];
251
- numbers.forEach(add);
252
- }
253
- return out;
254
- }
255
- if (!TEXT_KIND.test(kind)) return out;
256
- const d = String(step % 10);
257
- const phones = ['+1415555012' + d, '+919876543' + (210 + step), '9876543' + (210 + step), '415555012' + d,
258
- '+1 415-555-012' + d, '(415) 555-012' + d, '415-555-012' + d, '+44 7700 90012' + d, '07700 90012' + d];
259
- const emails = ['kryptheon' + tag + '@example.com'];
260
- const links = ['https://example.com/kryptheon' + tag];
261
- // The shapes valueFor offers, in its order, so a rule they already passed
262
- // still gets the value it always got.
263
- const words = [tag ? tag + ' kryptheon test' : 'kryptheon test', 'kryptheon' + tag + '@example.com', 'KN' + (tag || '1'),
264
- String(step + 1), 'https://example.com/kryptheon', 'kryptheon' + tag, 'Kryptheon' + tag, 'kn-' + (tag || '1')];
265
- const literals = [];
266
- for (const definition of definitions) {
267
- const quoted = /'((?:[^']|'')*)'/g;
268
- let one;
269
- while ((one = quoted.exec(definition)) !== null) literals.push(one[1].split("''").join("'"));
270
- }
271
- const codes = ['US', 'IN', 'USD', 'INR', 'EUR', 'en', 'en-US', '#00000' + d, '2026-01-0' + (1 + (step % 9)), '1234' + d, '56000' + d,
272
- 'kryptheon test value long enough for a rule on a minimum length ' + (tag || '0')];
273
- const name = String(column.name).toLowerCase();
274
- const first = /phone|mobile|whatsapp|tel|msisdn/.test(name) ? phones
275
- : /mail/.test(name) ? emails
276
- : /url|link|website|site|href/.test(name) ? links : [];
277
- for (const value of first.concat(words, literals, phones, emails, links, codes)) add(fitTo(value, kind));
278
- return out;
279
- }
280
-
281
- /**
282
- * The first values a column's CHECK constraints accept, asked of the
283
- * database itself: each rule is run on the value alone, the column given as
284
- * the value by a one-row subquery. A rule that also names another column
285
- * cannot be run alone and is left out; when no rule can be, nothing is
286
- * offered and the seeder goes on as before. Remembered per table, column and
287
- * row, so the attacks that insert later ask nothing again.
288
- *
289
- * Returns the accepted values, best first - possibly none.
290
- */
291
- const FITTED = new WeakMap();
292
- async function fittingValues(client, table, column, distinct) {
293
- const definitions = checksOn(table, column.name);
294
- if (!definitions.length) return [];
295
- const candidates = checkCandidates(column, definitions, distinct);
296
- if (!candidates.length) return [];
297
- let byTable = FITTED.get(table);
298
- if (!byTable) FITTED.set(table, (byTable = new Map()));
299
- const key = column.name + '\u0000' + String(distinct);
300
- if (byTable.has(key)) return byTable.get(key);
301
-
302
- const type = String(column.type);
303
- const alone = (expression, value) =>
304
- 'SELECT (' + expression + ') IS NOT FALSE AS ok FROM (SELECT ' + value + '::' + type + ' AS ' + quote(column.name) + ') AS kn_row';
305
- const runnable = [];
306
- for (const expression of definitions) {
307
- try {
308
- await client.query(alone(expression, 'NULL'));
309
- runnable.push(expression);
310
- } catch (err) {
311
- // Names another column, or something only the table can supply.
312
- }
313
- }
314
- let accepted = [];
315
- if (runnable.length) {
316
- const all = runnable.map((e) => '(' + e + ') IS NOT FALSE').join(' AND ');
317
- for (const value of candidates) {
318
- try {
319
- const { rows } = await client.query(
320
- 'SELECT ' + all + ' AS ok FROM (SELECT $1::' + type + ' AS ' + quote(column.name) + ') AS kn_row', [value]);
321
- if (rows[0] && rows[0].ok === true) accepted.push(value);
322
- // As many as the seeder will ever try; asking further changes nothing.
323
- if (accepted.length >= SHAPES_TO_TRY) break;
324
- } catch (err) {
325
- // A value its own type, or the rule, refuses outright.
326
- }
327
- }
328
- }
329
- byTable.set(key, accepted);
330
- return accepted;
331
- }
332
-
333
- /**
334
- * Sets of columns where exactly one may be filled in.
335
- *
336
- * Found on a blind test (HelixOps): documents belonged to a work order or to
337
- * an inspection, never both, written
338
- * CHECK ((work_order_id IS NOT NULL)::int + (inspection_id IS NOT NULL)::int = 1)
339
- * Both parents were there, so the seeder pointed the row at both, the CHECK
340
- * refused every attempt, and the table was never tested at all.
341
- *
342
- * Two spellings are read - that sum, and num_nonnulls(a, b, ...) = 1 - and
343
- * only when the constraint says nothing else. A CHECK with anything more in it
344
- * is left alone rather than half understood.
345
- */
346
- function exactlyOne(table) {
347
- const groups = [];
348
- const name = '("(?:[^"]|"")+"|[A-Za-z_][A-Za-z0-9_$]*)';
349
- const unquote = (text) => (text.charAt(0) === '"' ? text.slice(1, -1).split('""').join('"') : text);
350
- for (const constraint of table.constraints || []) {
351
- if (constraint.kind !== 'c') continue;
352
- const definition = String(constraint.definition || '');
353
- let columns = [];
354
- let rest = definition;
355
- const summed = new RegExp('\\(\\(' + name + ' IS NOT NULL\\)\\)::integer', 'gi');
356
- let match;
357
- while ((match = summed.exec(definition)) !== null) columns.push(unquote(match[1]));
358
- rest = rest.replace(summed, '').replace(/\+/g, '');
359
- if (!columns.length) {
360
- const counted = /num_nonnulls\(([^()]*)\)/i.exec(definition);
361
- if (counted) {
362
- const listed = new RegExp(name, 'g');
363
- let one;
364
- while ((one = listed.exec(counted[1])) !== null) columns.push(unquote(one[1]));
365
- rest = rest.replace(counted[0], '');
366
- }
367
- }
368
- // Nothing may be left but the CHECK itself, brackets and "= 1".
369
- if (columns.length < 2 || rest.replace(/[()\s]/g, '').toUpperCase() !== 'CHECK=1') continue;
370
- groups.push(columns);
371
- }
372
- return groups;
373
- }
374
-
375
- /**
376
- * The foreign keys on a table, read out of Postgres's own wording.
377
- *
378
- * Every column of the key, not just the first. A key over (org_id, cart_id)
379
- * used to be read as though it were only org_id: the second column got an
380
- * invented value, the pair pointed at no row that existed, and the table was
381
- * reported as one that could not be checked.
382
- */
383
- function foreignKeys(table) {
384
- const keys = [];
385
- const unquote = (text) => text.trim().split('"').join('');
386
- for (const constraint of table.constraints || []) {
387
- if (constraint.kind !== 'f') continue;
388
- const match = /FOREIGN KEY \(([^)]+)\) REFERENCES ([^(]+)\(([^)]+)\)/i.exec(constraint.definition);
389
- if (!match) continue;
390
- keys.push({
391
- columns: match[1].split(',').map(unquote),
392
- refTable: unquote(match[2].split('.').pop()),
393
- refColumns: match[3].split(',').map(unquote),
394
- });
395
- }
396
- return keys;
397
- }
398
-
399
- /** The single-column primary key of a table, or null if it has none. */
400
- function primaryKeyOf(table) {
401
- for (const constraint of table.constraints || []) {
402
- if (constraint.kind !== 'p') continue;
403
- const match = /PRIMARY KEY \(([^)]+)\)/i.exec(constraint.definition);
404
- if (!match) continue;
405
- const columns = match[1].split(',').map((name) => name.trim().split('"').join(''));
406
- // A composite key is not something a single `<thing>_id` column points at.
407
- return columns.length === 1 ? columns[0] : null;
408
- }
409
- return null;
410
- }
411
-
412
- /**
413
- * The table a column named `<thing>_id` is pointing at, if there is one.
414
- *
415
- * The name has to match a table that is really here, and the types have to
416
- * agree. Both, because `stripe_id` matches nothing and `org_id integer` does
417
- * not point at an `orgs.id` that is a uuid.
418
- */
419
- function parentFor(column, tables) {
420
- const stem = /^(.+)_id$/i.exec(column.name);
421
- if (!stem) return null;
422
- const wanted = stem[1].toLowerCase();
423
- const parent = (tables || []).find((table) => {
424
- const name = table.name.toLowerCase();
425
- return name === wanted || name === wanted + 's' || name === wanted + 'es';
426
- });
427
- if (!parent) return null;
428
-
429
- const key = primaryKeyOf(parent);
430
- if (!key) return null;
431
- const keyColumn = (parent.columns || []).find((c) => c.name === key);
432
- if (!keyColumn || keyColumn.type !== column.type) return null;
433
-
434
- return { table: parent, keyColumn: key, type: keyColumn.type };
435
- }
436
-
437
- /**
438
- * The keys a table would have if it had been given them: a `<thing>_id`
439
- * column naming a table that is here, with no foreign key on it.
440
- *
441
- * Found on a blind test. A production snapshot had no foreign keys at all, so
442
- * workspace_members.workspace_id was seeded with an invented value that named
443
- * no workspace. A view joining members to their workspaces then showed
444
- * nothing, and "anyone can list every workspace and who is in it" - a view
445
- * granted to logged-out visitors - read as an empty room.
446
- *
447
- * Only ever used to pick a value. Nothing is refused for lack of one: the
448
- * database never promised the parent would be there.
449
- */
450
- function impliedKeys(table, tables) {
451
- const tied = new Set();
452
- for (const key of foreignKeys(table)) key.columns.forEach((name) => tied.add(name));
453
- const keys = [];
454
- for (const column of table.columns || []) {
455
- if (tied.has(column.name)) continue;
456
- const parent = parentFor(column, tables);
457
- if (!parent || parent.table.name === table.name) continue;
458
- keys.push({ columns: [column.name], refTable: parent.table.name, refColumns: [parent.keyColumn] });
459
- }
460
- return keys;
461
- }
462
-
463
- /**
464
- * Parents before children.
465
- *
466
- * Tables come back in alphabetical order, which put `orders` before `profiles`
467
- * and made every insert fail on the foreign key - on the first real app it was
468
- * pointed at, because every app has one of these. A cycle is left in whatever
469
- * order it arrived: it cannot be satisfied anyway, and the table that fails is
470
- * reported rather than dropped.
471
- *
472
- * A key the app never declared counts too. A member row seeded before the
473
- * workspace it names has nothing real to point at.
474
- */
475
- function dependencyOrder(tables) {
476
- const byName = new Map(tables.map((t) => [t.name, t]));
477
- const ordered = [];
478
- const done = new Set();
479
- const visiting = new Set();
480
-
481
- const visit = (table) => {
482
- if (done.has(table.name) || visiting.has(table.name)) return;
483
- visiting.add(table.name);
484
- for (const key of foreignKeys(table).concat(impliedKeys(table, tables))) {
485
- const parent = byName.get(key.refTable);
486
- if (parent && parent.name !== table.name) visit(parent);
487
- }
488
- visiting.delete(table.name);
489
- done.add(table.name);
490
- ordered.push(table);
491
- };
492
-
493
- for (const table of tables) visit(table);
494
- return ordered;
495
- }
496
-
497
- /**
498
- * One row that is really in the parent table, so a foreign key is satisfied.
499
- *
500
- * The whole row, not one column of it. A composite key has to point at a pair
501
- * that exists together: borrowing each column from a separate row would build
502
- * a combination the parent never had.
503
- *
504
- * `mine` is the row seeded for the same person, when there is one. Without it
505
- * both fake people were put in the first workspace - the same team, both as
506
- * its owner - and every rule written "members of this workspace may read it"
507
- * let each read the other's rows. Six tables of one blind test were reported
508
- * as one customer reading another's data, when they were teammates.
509
- */
510
- async function existingRow(client, schema, key, mine) {
511
- if (mine) return key.refColumns.map((name) => mine[name]);
512
- try {
513
- const columns = key.refColumns.map((name, i) => quote(name) + ' AS v' + i).join(', ');
514
- const { rows } = await client.query(
515
- 'SELECT ' + columns + ' FROM ' + quote(schema) + '.' + quote(key.refTable) + ' LIMIT 1',
516
- );
517
- if (!rows.length) return null;
518
- return key.refColumns.map((name, i) => rows[0]['v' + i]);
519
- } catch (err) {
520
- return null;
521
- }
522
- }
523
-
524
- /**
525
- * A row that will actually go in.
526
- *
527
- * Owner column set to the person, foreign keys pointing at rows that really
528
- * exist, every NOT NULL column filled, and anything with a default left to
529
- * supply its own value.
530
- *
531
- * `overrides` is what the Collision attack needs: it forces one column to a
532
- * chosen value while everything else stays distinct, so when two inserts race
533
- * they collide on that column and on nothing else. Without it a second unique
534
- * column elsewhere in the table would refuse the insert, and the refusal would
535
- * be read as the app defending itself.
536
- *
537
- * `seeding` is only passed by the seeder: `rows`, the rows already seeded for
538
- * each person, table by table, so a row points at its own person's parent;
539
- * and `implied`, the keys the app never declared, so a `workspace_id` with no
540
- * foreign key still names a workspace that is there.
541
- */
542
- async function rowFor(client, schema, table, person, distinct, overrides, attempt, seeding) {
543
- const forced = overrides || {};
544
- const owner = ownerColumn(table);
545
- const columns = [];
546
- const values = [];
547
- const sown = (seeding && seeding.rows) || new Map();
548
- const implied = (seeding && seeding.implied) || [];
549
-
550
- // Every column that takes part in a foreign key, and the value it has to
551
- // hold. Resolved one key at a time so that all of a composite key's columns
552
- // come from the same parent row. Declared keys first, so a key the app wrote
553
- // always wins over one guessed from a name.
554
- const borrowed = new Map();
555
- for (const key of foreignKeys(table).concat(implied)) {
556
- const theirs = sown.get(key.refTable);
557
- const row = await existingRow(client, schema, key, theirs && theirs.get(person));
558
- if (!row) continue;
559
- key.columns.forEach((name, i) => {
560
- if (!borrowed.has(name)) borrowed.set(name, row[i]);
561
- });
562
- }
563
- const partOfKey = new Set();
564
- for (const key of foreignKeys(table)) key.columns.forEach((name) => partOfKey.add(name));
565
-
566
- // Where exactly one of a set may be filled in: the one already chosen, or
567
- // else the first that can be - a key needs a parent row to point at. The
568
- // rest are written as nothing, whatever their default.
569
- const chosen = (name) => Object.prototype.hasOwnProperty.call(forced, name);
570
- const onlyOne = new Map();
571
- for (const group of exactlyOne(table)) {
572
- const kept = group.find(chosen) || group.find((name) => !partOfKey.has(name) || borrowed.has(name));
573
- for (const name of group) onlyOne.set(name, name === kept);
574
- }
575
-
576
- for (const column of table.columns) {
577
- if (Object.prototype.hasOwnProperty.call(forced, column.name)) {
578
- columns.push(column.name);
579
- values.push(forced[column.name]);
580
- continue;
581
- }
582
- if (onlyOne.get(column.name) === false) {
583
- columns.push(column.name);
584
- values.push(null);
585
- continue;
586
- }
587
- const mustFill = onlyOne.get(column.name) === true;
588
- if (column.name === owner) {
589
- columns.push(column.name);
590
- values.push(person);
591
- continue;
592
- }
593
- // A column pointing at another table has to hold something that is
594
- // actually there, whatever its type would otherwise suggest.
595
- if (partOfKey.has(column.name)) {
596
- if (borrowed.has(column.name)) {
597
- columns.push(column.name);
598
- values.push(borrowed.get(column.name));
599
- continue;
600
- }
601
- if (column.not_null) throw new Error('nothing to point ' + column.name + ' at');
602
- continue;
603
- }
604
- // A generated column computes itself and refuses to be written to at all.
605
- //
606
- // Two halves, and only one of them can be observed. An identity column
607
- // carries no default_expr, so without the identity half the seeder writes
608
- // to it and Postgres refuses the row - both engines are caught doing it.
609
- // A stored generated column keeps its expression IN default_expr, so the
610
- // line below skips it whether or not this one does: removing the generated
611
- // half changes nothing any fixture could see. It stays because that is a
612
- // fact about how readColumns fills the shape, not about Postgres, and the
613
- // day it changes this is the only thing standing in the way.
614
- if (column.generated || column.identity) continue;
615
- // A key nobody declared, pointing at a parent that is there. Filled even
616
- // where a default or a null would do, because a row that names no real
617
- // parent is invisible to every rule and view that joins through it.
618
- if (borrowed.has(column.name)) {
619
- columns.push(column.name);
620
- values.push(borrowed.get(column.name));
621
- continue;
622
- }
623
- // Anything with a default can supply its own value. A column that may be
624
- // empty is left empty - unless it is the one of a set that has to be
625
- // filled in.
626
- if (column.default_expr) continue;
627
- if (!column.not_null && !mustFill) continue;
628
- columns.push(column.name);
629
- // A CHECK that lists what it will accept beats anything invented here;
630
- // after that, a value one it could not read was asked about and accepts.
631
- const allowed = allowedByCheck(table, column.name);
632
- const fitting = allowed.length ? [] : await fittingValues(client, table, column, distinct);
633
- const pick = (list) => list[Math.min(Math.max(attempt || 0, 0), list.length - 1)];
634
- values.push(
635
- allowed.length ? pick(allowed)
636
- : fitting.length ? pick(fitting)
637
- : valueFor(column, person, distinct, attempt),
638
- );
639
- }
640
-
641
- return { columns: columns, values: values };
642
- }
643
-
644
- /**
645
- * Puts a built row in. Separate so the same row can be raced against itself.
646
- * `back` asks for the row as it landed, defaults and all.
647
- */
648
- function insertRow(client, schema, table, row, back) {
649
- const where = 'INSERT INTO ' + quote(schema) + '.' + quote(table);
650
- const tail = back ? ' RETURNING *' : '';
651
- // A table of nothing but an id and its defaults leaves no columns to name,
652
- // and "INSERT INTO t () VALUES ()" is a syntax error. Postgres has a spelling
653
- // for exactly this, and without it every settings and flags table in the
654
- // world came back as one that could not be checked.
655
- if (!row.columns.length) return client.query(where + ' DEFAULT VALUES' + tail);
656
- const placeholders = row.values.map((_, i) => '$' + (i + 1));
657
- return client.query(
658
- where + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' + placeholders.join(', ') + ')' + tail,
659
- row.values,
660
- );
661
- }
662
-
663
- /**
664
- * The column that says which organization's a row is, on a table with no
665
- * person's name on it - or null. Its first key to a table that others point
666
- * at (an organization, a workspace, a team), where the table itself is not one
667
- * of those: seeding gives each person their own organization, and a table like
668
- * this one row, in the first person's. Found on a blind test (HarborLine):
669
- * integration_secrets(organization_id, access_token) was readable by every
670
- * signed-in user, and with no person column it was never tried at all.
671
- */
672
- function tenantKey(table, tables, parents) {
673
- // A view is not seeded: it shows whatever its tables hold, the second
674
- // person's own organization included, so a row of it read by them proves
675
- // nothing. Found on the same test: workspace_health, filtered to the
676
- // organizations the reader belongs to, came back as a leak.
677
- if (table.isView || parents.has(table.name)) return null;
678
- for (const key of foreignKeys(table).concat(impliedKeys(table, tables))) {
679
- if (key.refTable !== table.name && parents.has(key.refTable) && key.columns.length === 1) return key.columns[0];
680
- }
681
- return null;
682
- }
683
-
684
- /** Every table some other table points at, by a key it declared or one it implies. */
685
- function parentTables(tables) {
686
- const parents = new Set();
687
- for (const table of tables) {
688
- for (const key of foreignKeys(table).concat(impliedKeys(table, tables))) {
689
- if (key.refTable !== table.name) parents.add(key.refTable);
690
- }
691
- }
692
- return parents;
693
- }
694
-
695
- // How many differently-shaped values to try before giving up on a table. The
696
- // rules that reject the first attempt - a domain insisting on an @, a CHECK on
697
- // a length - cannot be read out of the catalogue, so the only honest way to
698
- // satisfy them is to offer something else and see.
699
- const SHAPES_TO_TRY = 5;
700
-
701
- /**
702
- * Two rows per table: one belonging to each fake person.
703
- *
704
- * Inserted as the owner of the schema, deliberately - seeding is not the
705
- * attack, and a policy that blocked the seed would leave nothing to attack.
706
- *
707
- * A table that cannot be seeded is recorded rather than skipped quietly. An
708
- * empty table reads as a safe table, so silently failing here would report an
709
- * app as secure because the tool could not get a row into it.
710
- */
711
- async function seed(client, schema, tables) {
712
- const seeded = [];
713
- const skipped = [];
714
- // Each person's own rows, table by table, so that what they own points at
715
- // their own workspace rather than the first one that went in.
716
- const rows = new Map();
717
- const parents = parentTables(tables);
718
- for (const table of dependencyOrder(tables)) {
719
- const owner = ownerColumn(table);
720
- const seeding = { rows: rows, implied: impliedKeys(table, tables) };
721
-
722
- // A table with nobody's name on it still gets a row. Without one, an open
723
- // door cannot be told from an empty room: a logged-out stranger reads zero
724
- // rows either way, and the tool reports the app as safe. Settings tables,
725
- // waitlists and contact forms are exactly this shape, and exactly the ones
726
- // that get left open.
727
- //
728
- // One that other tables point at - a workspace, a team, an organisation -
729
- // gets one for each person, so the two of them are in different teams
730
- // and a team rule is tested across teams rather than inside one. Some
731
- // tables only ever hold one row, so a pair that will not go in falls back
732
- // to the single row it always had.
733
- const casts = owner ? [[USER_A, USER_B]]
734
- : parents.has(table.name) ? [[USER_A, USER_B], [USER_A]] : [[USER_A]];
735
-
736
- // Each attempt offers a differently-shaped set of values. A table whose
737
- // rules reject all of them is reported, never quietly passed over.
738
- let refused = null;
739
- let landed = false;
740
- let worked = 0;
741
- for (const people of casts) {
742
- for (let attempt = 0; attempt < SHAPES_TO_TRY && !landed; attempt++) {
743
- const theirs = new Map();
744
- try {
745
- let nth = 0;
746
- for (const person of people) {
747
- nth += 1;
748
- const row = await rowFor(client, schema, table, person, nth, null, attempt, seeding);
749
- const result = await insertRow(client, schema, table.name, row, true);
750
- theirs.set(person, result.rows[0]);
751
- }
752
- landed = true;
753
- worked = attempt;
754
- rows.set(table.name, theirs);
755
- } catch (err) {
756
- refused = err.message;
757
- // A half-seeded table would make the next attempt collide with its own
758
- // first row, so anything that did go in is taken back out.
759
- await client
760
- .query('DELETE FROM ' + quote(schema) + '.' + quote(table.name))
761
- .catch(() => {});
762
- }
763
- }
764
- if (landed) break;
765
- }
766
-
767
- if (landed) {
768
- // The shape that worked is kept: any later attack that has to insert into
769
- // this table can use the same one instead of rediscovering it, and be
770
- // sure a refusal is the app defending itself rather than a CHECK it
771
- // never satisfied.
772
- const entry = { table: table.name, owner: owner, attempt: worked };
773
- // A table with nobody's name on it that got a row for each person - an
774
- // organization's invoices, say - holds the second person's own row too.
775
- // Which row is the first person's is kept, so a write meant for
776
- // "somebody else's row" is aimed at it. Found on a blind test
777
- // (HarborLine): an unscoped UPDATE changed the second person's own
778
- // invoice, and was reported as any customer changing another's.
779
- const firstRow = !owner && rows.get(table.name) && rows.get(table.name).get(USER_A);
780
- const pk = primaryKeyOf(table);
781
- if (firstRow && rows.get(table.name).size === 2 && pk && firstRow[pk] !== null && firstRow[pk] !== undefined) {
782
- entry.rowOfA = { column: pk, value: String(firstRow[pk]) };
783
- }
784
- seeded.push(entry);
785
- } else {
786
- // Recorded, never swallowed. The report has to say this table was not
787
- // checked rather than let an empty table pass for a safe one.
788
- skipped.push({ table: table.name, why: refused });
789
- }
790
- }
791
- return { seeded: seeded, skipped: skipped };
792
- }
793
-
794
- /** Reads a table the way a request would, as whoever is asking. */
795
- async function readAs(client, schema, table, role, userId) {
796
- await client.query('BEGIN');
797
- try {
798
- await client.query('SET LOCAL role TO ' + role);
799
- // A logged-out visitor is not "no claims". Supabase hands PostgREST the
800
- // anon key, which is itself a JWT, so request.jwt.claims arrives as a real
801
- // JSON object that simply has no `sub` in it.
802
- //
803
- // Sending an empty string instead made auth.uid() throw on the cast, and a
804
- // read that throws returns no rows - which is exactly what a properly
805
- // secured table returns. Every table whose policy calls auth.uid(), which
806
- // is nearly every table anyone writes, came back looking safe without the
807
- // rule ever being evaluated.
808
- await client.query('SELECT set_config($1, $2, true)', [
809
- 'request.jwt.claims',
810
- JSON.stringify(userId ? { sub: userId, role: role } : { role: role }),
811
- ]);
812
- const result = await client.query('SELECT * FROM ' + quote(schema) + '.' + quote(table));
813
- return result.rows;
814
- } catch (err) {
815
- // A refusal is an answer: the table is not reachable by this caller at all.
816
- return { blocked: err.message };
817
- } finally {
818
- await client.query('ROLLBACK');
819
- }
820
- }
821
-
822
- function rowsOwnedBy(rows, owner, person) {
823
- if (!Array.isArray(rows)) return 0;
824
- return rows.filter((r) => String(r[owner]) === person).length;
825
- }
826
-
827
- /**
828
- * A refusal is only an answer when it is the right refusal.
829
- *
830
- * "permission denied for table orders" means this caller cannot reach the
831
- * table at all. That is the attack being defeated, and it is good news worth
832
- * recording as a pass.
833
- *
834
- * Everything else - a schema the policy needs and the role cannot use, a
835
- * function the policy calls that is not there, a timeout - means the rule was
836
- * never evaluated. No verdict exists. Both come back as an error and return
837
- * zero rows, and zero rows is exactly what a perfectly secured table returns,
838
- * so telling them apart is the difference between "you are safe" and "I could
839
- * not tell", which is the difference the whole product rests on.
840
- */
841
- function refusalMeans(message) {
842
- // The multi-word kinds come first. Postgres says "permission denied for
843
- // materialized view hits", and an alternation that tried `view` first would
844
- // never reach it - so a matview nobody had granted was filed as untested
845
- // rather than as the attack being beaten, and a correct app collected a
846
- // warning it had not earned.
847
- const denied = /permission denied for (materialized view|foreign table|partitioned table|table|relation|view|sequence)/i;
848
- return denied.test(String(message)) ? 'unreachable' : 'untested';
849
- }
850
-
851
- /**
852
- * The table whose rule looks itself up, or null.
853
- *
854
- * A rule on organization_members that asks organization_members who is a
855
- * member has to be checked to be checked, and Postgres stops the request
856
- * rather than loop. Found on a blind test: every signed-in read touching that
857
- * table failed, the app was broken for everyone with an account, and the
858
- * report listed it only as a reason something was not tested. It is a finding
859
- * in its own right - and it names the table at fault, which is often not the
860
- * one that was being read.
861
- */
862
- /**
863
- * The other shape the same loop takes. When the rule reaches its own table
864
- * through a helper function rather than directly, Postgres does not see a
865
- * policy recursing - it runs out of stack - and the message names no table.
866
- * Found on a blind test: every signed-in read failed with "stack depth limit
867
- * exceeded", and the report filed each one only as untested.
868
- */
869
- function tooDeep(message) {
870
- return /stack depth limit exceeded/i.test(String(message || ''));
871
- }
872
-
873
- function recursionIn(message) {
874
- const found = /infinite recursion detected in policy for relation "([^"]+)"/i.exec(String(message || ''));
875
- return found ? found[1] : null;
876
- }
877
-
878
- /**
879
- * What each table gives away, and to whom.
880
- *
881
- * Two separate findings, because they are two different conversations with the
882
- * person who has to fix it:
883
- *
884
- * exposed - a logged-out stranger can read the table. This is the one that
885
- * ends up on a news site.
886
- * crossed - a signed-in customer can read another customer's rows. Quieter,
887
- * and the one that breaks trust with the people already paying.
888
- */
889
- async function impersonate(client, schema, tables) {
890
- const findings = [];
891
- const completed = [];
892
- const blocked = [];
893
- // Rules that looked themselves up, and which read ran into each one.
894
- const looped = [];
895
-
896
- /** Did this read produce a verdict, and if not, why not? */
897
- const settle = (key, table, answer, as) => {
898
- if (Array.isArray(answer)) {
899
- completed.push(key);
900
- return true;
901
- }
902
- if (refusalMeans(answer.blocked) === 'unreachable') {
903
- // Refused outright. The attack ran and lost, which is the result we want
904
- // for a table that is properly closed.
905
- completed.push(key);
906
- return false;
907
- }
908
- blocked.push({ table: table, key: key, why: as + ': ' + answer.blocked });
909
- return false;
910
- };
911
-
912
- const parents = parentTables(tables);
913
- for (const table of tables) {
914
- const owner = ownerColumn(table);
915
- const tenant = owner ? null : tenantKey(table, tables, parents);
916
- const anon = await readAs(client, schema, table.name, 'anon', null);
917
- const asA = await readAs(client, schema, table.name, 'authenticated', USER_A);
918
-
919
- // Looked for on every table, owner or not: a signed-in read of a table
920
- // with nobody's name on it fails just the same, and it is the same
921
- // broken page.
922
- for (const [answer, who] of [[anon, 'anyone'], [asA, 'signed-in']]) {
923
- if (Array.isArray(answer)) continue;
924
- const relation = recursionIn(answer.blocked);
925
- // A loop with no table named is recorded with none; the scan works out
926
- // which table's rule it is from the rules and helpers it has read.
927
- if (relation || tooDeep(answer.blocked)) looped.push({ relation: relation || null, table: table.name, who: who });
928
- }
929
- // Only a read of the table itself that came back with a verdict, for both
930
- // callers, shows its rules no longer loop. A read that failed for some
931
- // other reason shows nothing either way.
932
- const answered = (answer) => Array.isArray(answer) || refusalMeans(answer.blocked) === 'unreachable';
933
- if (answered(anon) && answered(asA)) completed.push('recursive:' + table.name);
934
-
935
- if (settle('exposed:' + table.name, table.name, anon, 'as a logged-out visitor') && anon.length > 0) {
936
- findings.push({
937
- kind: 'exposed',
938
- table: table.name,
939
- readable: anon.length,
940
- columns: Object.keys(anon[0] || {}),
941
- rlsEnabled: table.rlsEnabled,
942
- isView: Boolean(table.isView),
943
- });
944
- }
945
-
946
- // Crossed is only ever looked for where a row says who it belongs to, so
947
- // on a table with no owner there is no attack to record either way.
948
- if (owner && settle('crossed:' + table.name, table.name, asA, 'as a signed-in customer')) {
949
- const theirs = rowsOwnedBy(asA, owner, USER_B);
950
- if (theirs > 0) {
951
- findings.push({
952
- kind: 'crossed',
953
- table: table.name,
954
- owner: owner,
955
- readable: theirs,
956
- columns: Object.keys(asA[0] || {}),
957
- rlsEnabled: table.rlsEnabled,
958
- });
959
- }
960
- }
961
-
962
- // A table whose rows are an organization's, not a person's: its one row is
963
- // the first person's organization's, and the second person - signed in,
964
- // in an organization of their own - asks for it. What a logged-out
965
- // stranger can already read is reported as exposed, and not twice.
966
- if (tenant) {
967
- const asB = await readAs(client, schema, table.name, 'authenticated', USER_B);
968
- const open = Array.isArray(anon) && anon.length > 0;
969
- if (settle('crossed:' + table.name, table.name, asB, 'as a signed-in user in another organization') &&
970
- asB.length > 0 && !open) {
971
- findings.push({
972
- kind: 'crossed',
973
- table: table.name,
974
- owner: tenant,
975
- tenant: true,
976
- readable: asB.length,
977
- columns: Object.keys(asB[0] || {}),
978
- rlsEnabled: table.rlsEnabled,
979
- });
980
- }
981
- }
982
- }
983
-
984
- return { findings: findings, completed: completed, blocked: blocked, looped: looped };
985
- }
986
-
987
- /** A stable shape for comparing one run against another. */
988
- function summarise(result) {
989
- const findings = Array.isArray(result) ? result : (result && result.findings) || [];
990
- return findings
991
- .map((f) => f.kind + ':' + f.table + ':' + f.readable)
992
- .sort()
993
- .join(' | ');
994
- }
995
-
996
- module.exports = {
997
- USER_A: USER_A,
998
- USER_B: USER_B,
999
- USER_C: USER_C,
1000
- USER_D: USER_D,
1001
- refusalMeans: refusalMeans,
1002
- recursionIn: recursionIn,
1003
- tooDeep: tooDeep,
1004
- ownerColumn: ownerColumn,
1005
- fitTo: fitTo,
1006
- foreignKeys: foreignKeys,
1007
- primaryKeyOf: primaryKeyOf,
1008
- parentFor: parentFor,
1009
- impliedKeys: impliedKeys,
1010
- parentTables: parentTables,
1011
- dependencyOrder: dependencyOrder,
1012
- valueFor: valueFor,
1013
- allowedByCheck: allowedByCheck,
1014
- checksOn: checksOn,
1015
- checkCandidates: checkCandidates,
1016
- fittingValues: fittingValues,
1017
- exactlyOne: exactlyOne,
1018
- rowFor: rowFor,
1019
- existingRow: existingRow,
1020
- insertRow: insertRow,
1021
- seed: seed,
1022
- readAs: readAs,
1023
- impersonate: impersonate,
1024
- summarise: summarise,
1025
- };
1
+ // The impersonation attack.
2
+ //
3
+ // Sign in as one person, ask for another person's data, and see what comes
4
+ // back. This is the attack a recorded browser flow can never find, because the
5
+ // frontend never even tries to ask for somebody else's rows - it only ever
6
+ // requests what the signed-in person is supposed to have.
7
+ //
8
+ // It runs the way a real request runs: become the role PostgREST becomes, put
9
+ // the caller's identity where PostgREST puts it, then read. Verified against a
10
+ // real database in probe-rls.js before any of this was written.
11
+ //
12
+ // Two fake people are seeded and nothing else is ever inserted, so a finding is
13
+ // always about rows this tool created. No real customer is involved at any
14
+ // point, which is the promise the whole product is sold on.
15
+
16
+ const { quote } = require('./schema.js');
17
+
18
+ const USER_A = '11111111-1111-4111-8111-111111111111';
19
+ const USER_B = '22222222-2222-4222-8222-222222222222';
20
+
21
+ // Two more who own nothing at all. The seeded pair already hold a row each,
22
+ // so an attack that inserts under their name collides with the row the seeder
23
+ // put there - on a table keyed by the person, that is a primary key clash, and
24
+ // it was being read as the app refusing the attack. These are for any attack
25
+ // that has to add a row of its own.
26
+ const USER_C = '55555555-5555-4555-8555-555555555555';
27
+ const USER_D = '66666666-6666-4666-8666-666666666666';
28
+
29
+ // The names an owner column is actually given, in the order they are worth
30
+ // believing. `id` is last and only counts on a table that looks like a profile,
31
+ // where the row id IS the person.
32
+ const OWNER_NAMES = ['user_id', 'owner_id', 'owner', 'profile_id', 'account_id', 'created_by', 'author_id'];
33
+
34
+ // A key to Supabase's auth.users as the app wrote it. In the copy it points
35
+ // at a stand-in named kn_ext__auth__users, which the table-of-people rule
36
+ // below already recognises; this one is for a table itself called users.
37
+ const TO_AUTH_USERS = /REFERENCES\s+"?auth"?\s*\.\s*"?users"?\s*\(/i;
38
+ // A table that looks like it holds people, keyed by the person.
39
+ const PERSON_TABLE = /(profile|user|account|member)/i;
40
+
41
+ /**
42
+ * The single-column keys from a uuid column, with what they point at. Read
43
+ * from the constraint wording itself, because a key to auth.users and a key
44
+ * to public.users are different things and the schema is part of the words.
45
+ */
46
+ function personKeys(table, uuids) {
47
+ const byName = new Set(uuids.map((c) => c.name));
48
+ const keys = [];
49
+ for (const constraint of table.constraints || []) {
50
+ if (constraint.kind !== 'f') continue;
51
+ const definition = String(constraint.definition || '');
52
+ const match = /FOREIGN KEY \(([^)]+)\) REFERENCES ([^(]+)\(([^)]+)\)/i.exec(definition);
53
+ if (!match) continue;
54
+ const columns = match[1].split(',').map((t) => t.trim().split('"').join(''));
55
+ const refColumns = match[3].split(',').map((t) => t.trim().split('"').join(''));
56
+ if (columns.length !== 1 || !byName.has(columns[0])) continue;
57
+ keys.push({
58
+ column: columns[0],
59
+ toAuth: TO_AUTH_USERS.test(definition),
60
+ refTable: match[2].split('.').pop().trim().split('"').join(''),
61
+ refColumn: refColumns[0],
62
+ });
63
+ }
64
+ return keys;
65
+ }
66
+
67
+ /** The column that says who a row belongs to, or null if nothing does. */
68
+ function ownerColumn(table) {
69
+ const uuids = table.columns.filter((c) => c.type === 'uuid');
70
+ for (const name of OWNER_NAMES) {
71
+ const found = uuids.find((c) => c.name === name);
72
+ if (found) return found.name;
73
+ }
74
+ // Not named like an owner, but pointing at a person. Found on a blind test:
75
+ // appointments.provider_id -> profiles.id -> auth.users. Judged by name
76
+ // alone it had no owner, so "one provider reads another's patients" - a
77
+ // rule written USING (true) - was never tried, and the worst hole in the
78
+ // app went unreported. `id` first: a table keyed by the person is theirs.
79
+ // Two people on one row - sender_id and recipient_id - and neither is
80
+ // named: whose row it is cannot be told, and guessing would report one
81
+ // person reading a message sent to them as a break. So only one, or none.
82
+ const people = personKeys(table, uuids).filter((k) => k.toAuth ||
83
+ (k.refColumn === 'id' && k.refTable !== table.name && PERSON_TABLE.test(k.refTable)));
84
+ const columns = Array.from(new Set(people.map((k) => k.column)));
85
+ if (columns.includes('id')) return 'id';
86
+ if (columns.length === 1) return columns[0];
87
+ // A profiles table keyed by the person themselves.
88
+ if (/profile|user|account|member/i.test(table.name)) {
89
+ const id = uuids.find((c) => c.name === 'id');
90
+ if (id) return id.name;
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /** Keeps a generated value inside a declared width like varchar(20). */
96
+ function fitTo(text, type) {
97
+ const match = /^[a-z ]*\((\d+)\)$/.exec(String(type).trim());
98
+ if (!match) return text;
99
+ const limit = Number(match[1]);
100
+ return text.length > limit ? text.slice(0, limit) : text;
101
+ }
102
+
103
+ /**
104
+ * Something valid to put in a column, so a row can exist at all.
105
+ *
106
+ * `distinct` is what keeps two seeded rows from being identical. Without it
107
+ * every row carried the same text, so a table with a unique email column
108
+ * refused the second insert and the whole table was reported as "not checked"
109
+ * - a well-built app treated as an unknown one. The tag goes at the front
110
+ * because a narrow varchar truncates the end, and two rows truncated to the
111
+ * same string is that bug all over again.
112
+ */
113
+ function valueFor(column, owner, distinct, attempt) {
114
+ // A domain is somebody's own type with a rule bolted on. The rule cannot be
115
+ // guessed at from here, but the type underneath it can be filled in.
116
+ const type = String(column.base_type || column.type).toLowerCase();
117
+ const tag = distinct === undefined || distinct === null ? '' : String(distinct);
118
+ const step = Number(tag) || 0;
119
+
120
+ // An enum accepts one of a fixed list and nothing else. Every generated
121
+ // string was rejected, and the table went down as "not checked".
122
+ //
123
+ // Insisting on a real array rather than accepting anything with a length:
124
+ // the labels arrived once as the string "{new,paid,shipped}", whose first
125
+ // element is the character "{", and that is a value the database rejects
126
+ // just as firmly while looking like the code is working.
127
+ if (Array.isArray(column.enum_labels) && column.enum_labels.length) return column.enum_labels[0];
128
+ // Anything at all is allowed in an empty array, whatever the element type.
129
+ if (column.is_array || /\[\]$/.test(type)) return '{}';
130
+
131
+ if (type === 'uuid') return owner;
132
+ if (/^(integer|bigint|smallint|numeric|decimal|real|double|money)/.test(type)) return 1 + step;
133
+ if (/^bool/.test(type)) return true;
134
+ if (/^(timestamp|date)/.test(type)) return new Date().toISOString();
135
+ if (/^time/.test(type)) return '12:00:00';
136
+ if (/^interval/.test(type)) return '1 day';
137
+ if (/^json/.test(type)) return '{}';
138
+ if (/^(inet|cidr)/.test(type)) return '192.0.2.' + (1 + step);
139
+ if (/^macaddr8/.test(type)) return '08:00:2b:01:02:03:04:0' + (5 + step);
140
+ if (/^macaddr/.test(type)) return '08:00:2b:01:02:0' + (3 + step);
141
+ if (/^(tsvector|tsquery)/.test(type)) return 'kryptheon';
142
+ if (/^bytea/.test(type)) return Buffer.from('kryptheon');
143
+ if (/^xml/.test(type)) return '<kryptheon/>';
144
+ if (/^bit/.test(type)) return '0';
145
+ if (/^(point|line|lseg|box|path|polygon|circle)/.test(type)) return '(0,0)';
146
+
147
+ // Text is where the rules live that cannot be read: a domain that insists on
148
+ // an @, a CHECK on a length, a regex for a product code. Rather than pretend
149
+ // to understand them, the seeder works down a short ladder of shapes and
150
+ // keeps whichever one the database accepts.
151
+ const shapes = [
152
+ tag ? tag + ' kryptheon test' : 'kryptheon test',
153
+ 'kryptheon' + (tag || '') + '@example.com',
154
+ 'KN' + (tag || '1'),
155
+ String(step + 1),
156
+ 'https://example.com/kryptheon',
157
+ ];
158
+ return fitTo(shapes[Math.min(Math.max(attempt || 0, 0), shapes.length - 1)], type);
159
+ }
160
+
161
+ /**
162
+ * Values a CHECK constraint will actually accept for one column.
163
+ *
164
+ * `status text CHECK (status IN ('open','closed'))` is one of the most common
165
+ * things anyone writes, and Postgres stores it as
166
+ * `CHECK ((status = ANY (ARRAY['open'::text, 'closed'::text])))`. Reading the
167
+ * literals back out turns a table that could never be seeded into one that can.
168
+ *
169
+ * Only this one shape is understood, deliberately. A CHECK can contain
170
+ * anything, and pretending to satisfy an arbitrary one would mean inventing
171
+ * rows that the app itself would reject.
172
+ */
173
+ function allowedByCheck(table, columnName) {
174
+ const found = [];
175
+ const escaped = String(columnName).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
176
+ // Both spellings of the same rule. On a text column Postgres writes
177
+ // state = ANY (ARRAY['open'::text, ...])
178
+ // and on a varchar column it writes
179
+ // (state)::text = ANY ((ARRAY['open'::character varying, ...])::text[])
180
+ // which the first pattern could not cross: it stopped at the bracket that
181
+ // closes the cast. A varchar column with an IN list is an ordinary thing to
182
+ // write, and every table with one was seeded with an invented value, refused
183
+ // by its own constraint, and reported as a table that could not be checked.
184
+ //
185
+ // What may sit between the column and the = is spelled out rather than left
186
+ // to a negated class: anything looser reaches across the next AND and hands
187
+ // one column's allowed values to another.
188
+ const anyOf = new RegExp(
189
+ '\\b' + escaped + '\\b\\)?(?:::[a-z ]+)?\\s*=\\s*ANY\\s*\\(+\\s*ARRAY\\[(.*?)\\]',
190
+ 'i',
191
+ );
192
+ for (const constraint of table.constraints || []) {
193
+ if (constraint.kind !== 'c') continue;
194
+ const match = anyOf.exec(String(constraint.definition));
195
+ if (!match) continue;
196
+ // Picked out one at a time rather than split on commas, which came apart
197
+ // in the middle of any value that had a comma in it.
198
+ const literals = /'((?:[^']|'')*)'/g;
199
+ let literal;
200
+ while ((literal = literals.exec(match[1])) !== null) {
201
+ found.push(literal[1].split("''").join("'"));
202
+ }
203
+ }
204
+ return found;
205
+ }
206
+
207
+ /**
208
+ * The CHECK constraints on a table that name this column, as the expression
209
+ * inside CHECK (...). A NOT VALID one still applies to every new row.
210
+ */
211
+ function checksOn(table, columnName) {
212
+ const escaped = String(columnName).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
213
+ const named = new RegExp('(^|[^A-Za-z0-9_$"])("' + escaped.split('"').join('""') + '"|' + escaped + ')($|[^A-Za-z0-9_$"])');
214
+ const found = [];
215
+ for (const constraint of table.constraints || []) {
216
+ if (constraint.kind !== 'c') continue;
217
+ const definition = String(constraint.definition || '');
218
+ if (!named.test(definition)) continue;
219
+ found.push(definition.replace(/^CHECK\s*/i, '').replace(/\s+NOT VALID\s*$/i, ''));
220
+ }
221
+ return found;
222
+ }
223
+
224
+ const NUMERIC_KIND = /^(integer|bigint|smallint|numeric|decimal|real|double)/;
225
+ const TEXT_KIND = /^(text|character|varchar|citext)/;
226
+
227
+ /**
228
+ * Values to offer a column whose CHECK could not be read, best first.
229
+ *
230
+ * Found on a real app (a clinic's, 2026-10-11): patient_waitlist and
231
+ * whatsapp_contacts each had a phone number column with a CHECK on its
232
+ * format, compliance_requests a type column with a CHECK no reading here
233
+ * understood - and none of the made-up values fitted, so three tables, and a
234
+ * fourth that pointed at one of them, were never tested. Nothing here reads
235
+ * the rule. Each value is put to the rule itself, in the database
236
+ * (fittingValue), and the first it accepts is used.
237
+ *
238
+ * Mirrored by __KN__.check_candidates in engine.sql, value for value and in
239
+ * the same order: both engines must seed the same rows.
240
+ */
241
+ function checkCandidates(column, definitions, distinct) {
242
+ const kind = String(column.base_type || column.type).toLowerCase();
243
+ const tag = distinct === undefined || distinct === null ? '' : String(distinct);
244
+ const step = Number(tag) || 0;
245
+ const out = [];
246
+ const add = (value) => { if (out.indexOf(value) === -1) out.push(value); };
247
+ if (NUMERIC_KIND.test(kind)) {
248
+ [1 + step, 18 + step, 100 + step, 1000 + step, 0, 5].forEach((n) => add(String(n)));
249
+ for (const definition of definitions) {
250
+ const numbers = definition.match(/-?\d+(?:\.\d+)?/g) || [];
251
+ numbers.forEach(add);
252
+ }
253
+ return out;
254
+ }
255
+ if (!TEXT_KIND.test(kind)) return out;
256
+ const d = String(step % 10);
257
+ const phones = ['+1415555012' + d, '+919876543' + (210 + step), '9876543' + (210 + step), '415555012' + d,
258
+ '+1 415-555-012' + d, '(415) 555-012' + d, '415-555-012' + d, '+44 7700 90012' + d, '07700 90012' + d];
259
+ const emails = ['kryptheon' + tag + '@example.com'];
260
+ const links = ['https://example.com/kryptheon' + tag];
261
+ // The shapes valueFor offers, in its order, so a rule they already passed
262
+ // still gets the value it always got.
263
+ const words = [tag ? tag + ' kryptheon test' : 'kryptheon test', 'kryptheon' + tag + '@example.com', 'KN' + (tag || '1'),
264
+ String(step + 1), 'https://example.com/kryptheon', 'kryptheon' + tag, 'Kryptheon' + tag, 'kn-' + (tag || '1')];
265
+ const literals = [];
266
+ for (const definition of definitions) {
267
+ const quoted = /'((?:[^']|'')*)'/g;
268
+ let one;
269
+ while ((one = quoted.exec(definition)) !== null) literals.push(one[1].split("''").join("'"));
270
+ }
271
+ const codes = ['US', 'IN', 'USD', 'INR', 'EUR', 'en', 'en-US', '#00000' + d, '2026-01-0' + (1 + (step % 9)), '1234' + d, '56000' + d,
272
+ 'kryptheon test value long enough for a rule on a minimum length ' + (tag || '0')];
273
+ const name = String(column.name).toLowerCase();
274
+ const first = /phone|mobile|whatsapp|tel|msisdn/.test(name) ? phones
275
+ : /mail/.test(name) ? emails
276
+ : /url|link|website|site|href/.test(name) ? links : [];
277
+ for (const value of first.concat(words, literals, phones, emails, links, codes)) add(fitTo(value, kind));
278
+ return out;
279
+ }
280
+
281
+ /**
282
+ * The first values a column's CHECK constraints accept, asked of the
283
+ * database itself: each rule is run on the value alone, the column given as
284
+ * the value by a one-row subquery. A rule that also names another column
285
+ * cannot be run alone and is left out; when no rule can be, nothing is
286
+ * offered and the seeder goes on as before. Remembered per table, column and
287
+ * row, so the attacks that insert later ask nothing again.
288
+ *
289
+ * Returns the accepted values, best first - possibly none.
290
+ */
291
+ const FITTED = new WeakMap();
292
+ async function fittingValues(client, table, column, distinct) {
293
+ const definitions = checksOn(table, column.name);
294
+ if (!definitions.length) return [];
295
+ const candidates = checkCandidates(column, definitions, distinct);
296
+ if (!candidates.length) return [];
297
+ let byTable = FITTED.get(table);
298
+ if (!byTable) FITTED.set(table, (byTable = new Map()));
299
+ const key = column.name + '\u0000' + String(distinct);
300
+ if (byTable.has(key)) return byTable.get(key);
301
+
302
+ const type = String(column.type);
303
+ const alone = (expression, value) =>
304
+ 'SELECT (' + expression + ') IS NOT FALSE AS ok FROM (SELECT ' + value + '::' + type + ' AS ' + quote(column.name) + ') AS kn_row';
305
+ const runnable = [];
306
+ for (const expression of definitions) {
307
+ try {
308
+ await client.query(alone(expression, 'NULL'));
309
+ runnable.push(expression);
310
+ } catch (err) {
311
+ // Names another column, or something only the table can supply.
312
+ }
313
+ }
314
+ let accepted = [];
315
+ if (runnable.length) {
316
+ const all = runnable.map((e) => '(' + e + ') IS NOT FALSE').join(' AND ');
317
+ for (const value of candidates) {
318
+ try {
319
+ const { rows } = await client.query(
320
+ 'SELECT ' + all + ' AS ok FROM (SELECT $1::' + type + ' AS ' + quote(column.name) + ') AS kn_row', [value]);
321
+ if (rows[0] && rows[0].ok === true) accepted.push(value);
322
+ // As many as the seeder will ever try; asking further changes nothing.
323
+ if (accepted.length >= SHAPES_TO_TRY) break;
324
+ } catch (err) {
325
+ // A value its own type, or the rule, refuses outright.
326
+ }
327
+ }
328
+ }
329
+ byTable.set(key, accepted);
330
+ return accepted;
331
+ }
332
+
333
+ /**
334
+ * Sets of columns where exactly one may be filled in.
335
+ *
336
+ * Found on a blind test (HelixOps): documents belonged to a work order or to
337
+ * an inspection, never both, written
338
+ * CHECK ((work_order_id IS NOT NULL)::int + (inspection_id IS NOT NULL)::int = 1)
339
+ * Both parents were there, so the seeder pointed the row at both, the CHECK
340
+ * refused every attempt, and the table was never tested at all.
341
+ *
342
+ * Two spellings are read - that sum, and num_nonnulls(a, b, ...) = 1 - and
343
+ * only when the constraint says nothing else. A CHECK with anything more in it
344
+ * is left alone rather than half understood.
345
+ */
346
+ function exactlyOne(table) {
347
+ const groups = [];
348
+ const name = '("(?:[^"]|"")+"|[A-Za-z_][A-Za-z0-9_$]*)';
349
+ const unquote = (text) => (text.charAt(0) === '"' ? text.slice(1, -1).split('""').join('"') : text);
350
+ for (const constraint of table.constraints || []) {
351
+ if (constraint.kind !== 'c') continue;
352
+ const definition = String(constraint.definition || '');
353
+ let columns = [];
354
+ let rest = definition;
355
+ const summed = new RegExp('\\(\\(' + name + ' IS NOT NULL\\)\\)::integer', 'gi');
356
+ let match;
357
+ while ((match = summed.exec(definition)) !== null) columns.push(unquote(match[1]));
358
+ rest = rest.replace(summed, '').replace(/\+/g, '');
359
+ if (!columns.length) {
360
+ const counted = /num_nonnulls\(([^()]*)\)/i.exec(definition);
361
+ if (counted) {
362
+ const listed = new RegExp(name, 'g');
363
+ let one;
364
+ while ((one = listed.exec(counted[1])) !== null) columns.push(unquote(one[1]));
365
+ rest = rest.replace(counted[0], '');
366
+ }
367
+ }
368
+ // Nothing may be left but the CHECK itself, brackets and "= 1".
369
+ if (columns.length < 2 || rest.replace(/[()\s]/g, '').toUpperCase() !== 'CHECK=1') continue;
370
+ groups.push(columns);
371
+ }
372
+ return groups;
373
+ }
374
+
375
+ /**
376
+ * The foreign keys on a table, read out of Postgres's own wording.
377
+ *
378
+ * Every column of the key, not just the first. A key over (org_id, cart_id)
379
+ * used to be read as though it were only org_id: the second column got an
380
+ * invented value, the pair pointed at no row that existed, and the table was
381
+ * reported as one that could not be checked.
382
+ */
383
+ function foreignKeys(table) {
384
+ const keys = [];
385
+ const unquote = (text) => text.trim().split('"').join('');
386
+ for (const constraint of table.constraints || []) {
387
+ if (constraint.kind !== 'f') continue;
388
+ const match = /FOREIGN KEY \(([^)]+)\) REFERENCES ([^(]+)\(([^)]+)\)/i.exec(constraint.definition);
389
+ if (!match) continue;
390
+ keys.push({
391
+ columns: match[1].split(',').map(unquote),
392
+ refTable: unquote(match[2].split('.').pop()),
393
+ refColumns: match[3].split(',').map(unquote),
394
+ });
395
+ }
396
+ return keys;
397
+ }
398
+
399
+ /** The single-column primary key of a table, or null if it has none. */
400
+ function primaryKeyOf(table) {
401
+ for (const constraint of table.constraints || []) {
402
+ if (constraint.kind !== 'p') continue;
403
+ const match = /PRIMARY KEY \(([^)]+)\)/i.exec(constraint.definition);
404
+ if (!match) continue;
405
+ const columns = match[1].split(',').map((name) => name.trim().split('"').join(''));
406
+ // A composite key is not something a single `<thing>_id` column points at.
407
+ return columns.length === 1 ? columns[0] : null;
408
+ }
409
+ return null;
410
+ }
411
+
412
+ /**
413
+ * The table a column named `<thing>_id` is pointing at, if there is one.
414
+ *
415
+ * The name has to match a table that is really here, and the types have to
416
+ * agree. Both, because `stripe_id` matches nothing and `org_id integer` does
417
+ * not point at an `orgs.id` that is a uuid.
418
+ */
419
+ function parentFor(column, tables) {
420
+ const stem = /^(.+)_id$/i.exec(column.name);
421
+ if (!stem) return null;
422
+ const wanted = stem[1].toLowerCase();
423
+ const parent = (tables || []).find((table) => {
424
+ const name = table.name.toLowerCase();
425
+ return name === wanted || name === wanted + 's' || name === wanted + 'es';
426
+ });
427
+ if (!parent) return null;
428
+
429
+ const key = primaryKeyOf(parent);
430
+ if (!key) return null;
431
+ const keyColumn = (parent.columns || []).find((c) => c.name === key);
432
+ if (!keyColumn || keyColumn.type !== column.type) return null;
433
+
434
+ return { table: parent, keyColumn: key, type: keyColumn.type };
435
+ }
436
+
437
+ /**
438
+ * The keys a table would have if it had been given them: a `<thing>_id`
439
+ * column naming a table that is here, with no foreign key on it.
440
+ *
441
+ * Found on a blind test. A production snapshot had no foreign keys at all, so
442
+ * workspace_members.workspace_id was seeded with an invented value that named
443
+ * no workspace. A view joining members to their workspaces then showed
444
+ * nothing, and "anyone can list every workspace and who is in it" - a view
445
+ * granted to logged-out visitors - read as an empty room.
446
+ *
447
+ * Only ever used to pick a value. Nothing is refused for lack of one: the
448
+ * database never promised the parent would be there.
449
+ */
450
+ function impliedKeys(table, tables) {
451
+ const tied = new Set();
452
+ for (const key of foreignKeys(table)) key.columns.forEach((name) => tied.add(name));
453
+ const keys = [];
454
+ for (const column of table.columns || []) {
455
+ if (tied.has(column.name)) continue;
456
+ const parent = parentFor(column, tables);
457
+ if (!parent || parent.table.name === table.name) continue;
458
+ keys.push({ columns: [column.name], refTable: parent.table.name, refColumns: [parent.keyColumn] });
459
+ }
460
+ return keys;
461
+ }
462
+
463
+ /**
464
+ * Parents before children.
465
+ *
466
+ * Tables come back in alphabetical order, which put `orders` before `profiles`
467
+ * and made every insert fail on the foreign key - on the first real app it was
468
+ * pointed at, because every app has one of these. A cycle is left in whatever
469
+ * order it arrived: it cannot be satisfied anyway, and the table that fails is
470
+ * reported rather than dropped.
471
+ *
472
+ * A key the app never declared counts too. A member row seeded before the
473
+ * workspace it names has nothing real to point at.
474
+ */
475
+ function dependencyOrder(tables) {
476
+ const byName = new Map(tables.map((t) => [t.name, t]));
477
+ const ordered = [];
478
+ const done = new Set();
479
+ const visiting = new Set();
480
+
481
+ const visit = (table) => {
482
+ if (done.has(table.name) || visiting.has(table.name)) return;
483
+ visiting.add(table.name);
484
+ for (const key of foreignKeys(table).concat(impliedKeys(table, tables))) {
485
+ const parent = byName.get(key.refTable);
486
+ if (parent && parent.name !== table.name) visit(parent);
487
+ }
488
+ visiting.delete(table.name);
489
+ done.add(table.name);
490
+ ordered.push(table);
491
+ };
492
+
493
+ for (const table of tables) visit(table);
494
+ return ordered;
495
+ }
496
+
497
+ /**
498
+ * One row that is really in the parent table, so a foreign key is satisfied.
499
+ *
500
+ * The whole row, not one column of it. A composite key has to point at a pair
501
+ * that exists together: borrowing each column from a separate row would build
502
+ * a combination the parent never had.
503
+ *
504
+ * `mine` is the row seeded for the same person, when there is one. Without it
505
+ * both fake people were put in the first workspace - the same team, both as
506
+ * its owner - and every rule written "members of this workspace may read it"
507
+ * let each read the other's rows. Six tables of one blind test were reported
508
+ * as one customer reading another's data, when they were teammates.
509
+ */
510
+ async function existingRow(client, schema, key, mine) {
511
+ if (mine) return key.refColumns.map((name) => mine[name]);
512
+ try {
513
+ const columns = key.refColumns.map((name, i) => quote(name) + ' AS v' + i).join(', ');
514
+ const { rows } = await client.query(
515
+ 'SELECT ' + columns + ' FROM ' + quote(schema) + '.' + quote(key.refTable) + ' LIMIT 1',
516
+ );
517
+ if (!rows.length) return null;
518
+ return key.refColumns.map((name, i) => rows[0]['v' + i]);
519
+ } catch (err) {
520
+ return null;
521
+ }
522
+ }
523
+
524
+ /**
525
+ * A row that will actually go in.
526
+ *
527
+ * Owner column set to the person, foreign keys pointing at rows that really
528
+ * exist, every NOT NULL column filled, and anything with a default left to
529
+ * supply its own value.
530
+ *
531
+ * `overrides` is what the Collision attack needs: it forces one column to a
532
+ * chosen value while everything else stays distinct, so when two inserts race
533
+ * they collide on that column and on nothing else. Without it a second unique
534
+ * column elsewhere in the table would refuse the insert, and the refusal would
535
+ * be read as the app defending itself.
536
+ *
537
+ * `seeding` is only passed by the seeder: `rows`, the rows already seeded for
538
+ * each person, table by table, so a row points at its own person's parent;
539
+ * and `implied`, the keys the app never declared, so a `workspace_id` with no
540
+ * foreign key still names a workspace that is there.
541
+ */
542
+ async function rowFor(client, schema, table, person, distinct, overrides, attempt, seeding) {
543
+ const forced = overrides || {};
544
+ const owner = ownerColumn(table);
545
+ const columns = [];
546
+ const values = [];
547
+ const sown = (seeding && seeding.rows) || new Map();
548
+ const implied = (seeding && seeding.implied) || [];
549
+
550
+ // Every column that takes part in a foreign key, and the value it has to
551
+ // hold. Resolved one key at a time so that all of a composite key's columns
552
+ // come from the same parent row. Declared keys first, so a key the app wrote
553
+ // always wins over one guessed from a name.
554
+ const borrowed = new Map();
555
+ for (const key of foreignKeys(table).concat(implied)) {
556
+ const theirs = sown.get(key.refTable);
557
+ const row = await existingRow(client, schema, key, theirs && theirs.get(person));
558
+ if (!row) continue;
559
+ key.columns.forEach((name, i) => {
560
+ if (!borrowed.has(name)) borrowed.set(name, row[i]);
561
+ });
562
+ }
563
+ const partOfKey = new Set();
564
+ for (const key of foreignKeys(table)) key.columns.forEach((name) => partOfKey.add(name));
565
+
566
+ // Where exactly one of a set may be filled in: the one already chosen, or
567
+ // else the first that can be - a key needs a parent row to point at. The
568
+ // rest are written as nothing, whatever their default.
569
+ const chosen = (name) => Object.prototype.hasOwnProperty.call(forced, name);
570
+ const onlyOne = new Map();
571
+ for (const group of exactlyOne(table)) {
572
+ const kept = group.find(chosen) || group.find((name) => !partOfKey.has(name) || borrowed.has(name));
573
+ for (const name of group) onlyOne.set(name, name === kept);
574
+ }
575
+
576
+ for (const column of table.columns) {
577
+ if (Object.prototype.hasOwnProperty.call(forced, column.name)) {
578
+ columns.push(column.name);
579
+ values.push(forced[column.name]);
580
+ continue;
581
+ }
582
+ if (onlyOne.get(column.name) === false) {
583
+ columns.push(column.name);
584
+ values.push(null);
585
+ continue;
586
+ }
587
+ const mustFill = onlyOne.get(column.name) === true;
588
+ if (column.name === owner) {
589
+ columns.push(column.name);
590
+ values.push(person);
591
+ continue;
592
+ }
593
+ // A column pointing at another table has to hold something that is
594
+ // actually there, whatever its type would otherwise suggest.
595
+ if (partOfKey.has(column.name)) {
596
+ if (borrowed.has(column.name)) {
597
+ columns.push(column.name);
598
+ values.push(borrowed.get(column.name));
599
+ continue;
600
+ }
601
+ if (column.not_null) throw new Error('nothing to point ' + column.name + ' at');
602
+ continue;
603
+ }
604
+ // A generated column computes itself and refuses to be written to at all.
605
+ //
606
+ // Two halves, and only one of them can be observed. An identity column
607
+ // carries no default_expr, so without the identity half the seeder writes
608
+ // to it and Postgres refuses the row - both engines are caught doing it.
609
+ // A stored generated column keeps its expression IN default_expr, so the
610
+ // line below skips it whether or not this one does: removing the generated
611
+ // half changes nothing any fixture could see. It stays because that is a
612
+ // fact about how readColumns fills the shape, not about Postgres, and the
613
+ // day it changes this is the only thing standing in the way.
614
+ if (column.generated || column.identity) continue;
615
+ // A key nobody declared, pointing at a parent that is there. Filled even
616
+ // where a default or a null would do, because a row that names no real
617
+ // parent is invisible to every rule and view that joins through it.
618
+ if (borrowed.has(column.name)) {
619
+ columns.push(column.name);
620
+ values.push(borrowed.get(column.name));
621
+ continue;
622
+ }
623
+ // Anything with a default can supply its own value. A column that may be
624
+ // empty is left empty - unless it is the one of a set that has to be
625
+ // filled in.
626
+ if (column.default_expr) continue;
627
+ if (!column.not_null && !mustFill) continue;
628
+ columns.push(column.name);
629
+ // A CHECK that lists what it will accept beats anything invented here;
630
+ // after that, a value one it could not read was asked about and accepts.
631
+ const allowed = allowedByCheck(table, column.name);
632
+ const fitting = allowed.length ? [] : await fittingValues(client, table, column, distinct);
633
+ const pick = (list) => list[Math.min(Math.max(attempt || 0, 0), list.length - 1)];
634
+ values.push(
635
+ allowed.length ? pick(allowed)
636
+ : fitting.length ? pick(fitting)
637
+ : valueFor(column, person, distinct, attempt),
638
+ );
639
+ }
640
+
641
+ return { columns: columns, values: values };
642
+ }
643
+
644
+ /**
645
+ * Puts a built row in. Separate so the same row can be raced against itself.
646
+ * `back` asks for the row as it landed, defaults and all.
647
+ */
648
+ function insertRow(client, schema, table, row, back) {
649
+ const where = 'INSERT INTO ' + quote(schema) + '.' + quote(table);
650
+ const tail = back ? ' RETURNING *' : '';
651
+ // A table of nothing but an id and its defaults leaves no columns to name,
652
+ // and "INSERT INTO t () VALUES ()" is a syntax error. Postgres has a spelling
653
+ // for exactly this, and without it every settings and flags table in the
654
+ // world came back as one that could not be checked.
655
+ if (!row.columns.length) return client.query(where + ' DEFAULT VALUES' + tail);
656
+ const placeholders = row.values.map((_, i) => '$' + (i + 1));
657
+ return client.query(
658
+ where + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' + placeholders.join(', ') + ')' + tail,
659
+ row.values,
660
+ );
661
+ }
662
+
663
+ /**
664
+ * The column that says which organization's a row is, on a table with no
665
+ * person's name on it - or null. Its first key to a table that others point
666
+ * at (an organization, a workspace, a team), where the table itself is not one
667
+ * of those: seeding gives each person their own organization, and a table like
668
+ * this one row, in the first person's. Found on a blind test (HarborLine):
669
+ * integration_secrets(organization_id, access_token) was readable by every
670
+ * signed-in user, and with no person column it was never tried at all.
671
+ */
672
+ function tenantKey(table, tables, parents) {
673
+ // A view is not seeded: it shows whatever its tables hold, the second
674
+ // person's own organization included, so a row of it read by them proves
675
+ // nothing. Found on the same test: workspace_health, filtered to the
676
+ // organizations the reader belongs to, came back as a leak.
677
+ if (table.isView || parents.has(table.name)) return null;
678
+ for (const key of foreignKeys(table).concat(impliedKeys(table, tables))) {
679
+ if (key.refTable !== table.name && parents.has(key.refTable) && key.columns.length === 1) return key.columns[0];
680
+ }
681
+ return null;
682
+ }
683
+
684
+ /** Every table some other table points at, by a key it declared or one it implies. */
685
+ function parentTables(tables) {
686
+ const parents = new Set();
687
+ for (const table of tables) {
688
+ for (const key of foreignKeys(table).concat(impliedKeys(table, tables))) {
689
+ if (key.refTable !== table.name) parents.add(key.refTable);
690
+ }
691
+ }
692
+ return parents;
693
+ }
694
+
695
+ // How many differently-shaped values to try before giving up on a table. The
696
+ // rules that reject the first attempt - a domain insisting on an @, a CHECK on
697
+ // a length - cannot be read out of the catalogue, so the only honest way to
698
+ // satisfy them is to offer something else and see.
699
+ const SHAPES_TO_TRY = 5;
700
+
701
+ /**
702
+ * Two rows per table: one belonging to each fake person.
703
+ *
704
+ * Inserted as the owner of the schema, deliberately - seeding is not the
705
+ * attack, and a policy that blocked the seed would leave nothing to attack.
706
+ *
707
+ * A table that cannot be seeded is recorded rather than skipped quietly. An
708
+ * empty table reads as a safe table, so silently failing here would report an
709
+ * app as secure because the tool could not get a row into it.
710
+ */
711
+ async function seed(client, schema, tables) {
712
+ const seeded = [];
713
+ const skipped = [];
714
+ // Each person's own rows, table by table, so that what they own points at
715
+ // their own workspace rather than the first one that went in.
716
+ const rows = new Map();
717
+ const parents = parentTables(tables);
718
+ for (const table of dependencyOrder(tables)) {
719
+ const owner = ownerColumn(table);
720
+ const seeding = { rows: rows, implied: impliedKeys(table, tables) };
721
+
722
+ // A table with nobody's name on it still gets a row. Without one, an open
723
+ // door cannot be told from an empty room: a logged-out stranger reads zero
724
+ // rows either way, and the tool reports the app as safe. Settings tables,
725
+ // waitlists and contact forms are exactly this shape, and exactly the ones
726
+ // that get left open.
727
+ //
728
+ // One that other tables point at - a workspace, a team, an organisation -
729
+ // gets one for each person, so the two of them are in different teams
730
+ // and a team rule is tested across teams rather than inside one. Some
731
+ // tables only ever hold one row, so a pair that will not go in falls back
732
+ // to the single row it always had.
733
+ const casts = owner ? [[USER_A, USER_B]]
734
+ : parents.has(table.name) ? [[USER_A, USER_B], [USER_A]] : [[USER_A]];
735
+
736
+ // Each attempt offers a differently-shaped set of values. A table whose
737
+ // rules reject all of them is reported, never quietly passed over.
738
+ let refused = null;
739
+ let landed = false;
740
+ let worked = 0;
741
+ for (const people of casts) {
742
+ for (let attempt = 0; attempt < SHAPES_TO_TRY && !landed; attempt++) {
743
+ const theirs = new Map();
744
+ try {
745
+ let nth = 0;
746
+ for (const person of people) {
747
+ nth += 1;
748
+ const row = await rowFor(client, schema, table, person, nth, null, attempt, seeding);
749
+ const result = await insertRow(client, schema, table.name, row, true);
750
+ theirs.set(person, result.rows[0]);
751
+ }
752
+ landed = true;
753
+ worked = attempt;
754
+ rows.set(table.name, theirs);
755
+ } catch (err) {
756
+ refused = err.message;
757
+ // A half-seeded table would make the next attempt collide with its own
758
+ // first row, so anything that did go in is taken back out.
759
+ await client
760
+ .query('DELETE FROM ' + quote(schema) + '.' + quote(table.name))
761
+ .catch(() => {});
762
+ }
763
+ }
764
+ if (landed) break;
765
+ }
766
+
767
+ if (landed) {
768
+ // The shape that worked is kept: any later attack that has to insert into
769
+ // this table can use the same one instead of rediscovering it, and be
770
+ // sure a refusal is the app defending itself rather than a CHECK it
771
+ // never satisfied.
772
+ const entry = { table: table.name, owner: owner, attempt: worked };
773
+ // A table with nobody's name on it that got a row for each person - an
774
+ // organization's invoices, say - holds the second person's own row too.
775
+ // Which row is the first person's is kept, so a write meant for
776
+ // "somebody else's row" is aimed at it. Found on a blind test
777
+ // (HarborLine): an unscoped UPDATE changed the second person's own
778
+ // invoice, and was reported as any customer changing another's.
779
+ const firstRow = !owner && rows.get(table.name) && rows.get(table.name).get(USER_A);
780
+ const pk = primaryKeyOf(table);
781
+ if (firstRow && rows.get(table.name).size === 2 && pk && firstRow[pk] !== null && firstRow[pk] !== undefined) {
782
+ entry.rowOfA = { column: pk, value: String(firstRow[pk]) };
783
+ }
784
+ seeded.push(entry);
785
+ } else {
786
+ // Recorded, never swallowed. The report has to say this table was not
787
+ // checked rather than let an empty table pass for a safe one.
788
+ skipped.push({ table: table.name, why: refused });
789
+ }
790
+ }
791
+ return { seeded: seeded, skipped: skipped };
792
+ }
793
+
794
+ /** Reads a table the way a request would, as whoever is asking. */
795
+ async function readAs(client, schema, table, role, userId) {
796
+ await client.query('BEGIN');
797
+ try {
798
+ await client.query('SET LOCAL role TO ' + role);
799
+ // A logged-out visitor is not "no claims". Supabase hands PostgREST the
800
+ // anon key, which is itself a JWT, so request.jwt.claims arrives as a real
801
+ // JSON object that simply has no `sub` in it.
802
+ //
803
+ // Sending an empty string instead made auth.uid() throw on the cast, and a
804
+ // read that throws returns no rows - which is exactly what a properly
805
+ // secured table returns. Every table whose policy calls auth.uid(), which
806
+ // is nearly every table anyone writes, came back looking safe without the
807
+ // rule ever being evaluated.
808
+ await client.query('SELECT set_config($1, $2, true)', [
809
+ 'request.jwt.claims',
810
+ JSON.stringify(userId ? { sub: userId, role: role } : { role: role }),
811
+ ]);
812
+ const result = await client.query('SELECT * FROM ' + quote(schema) + '.' + quote(table));
813
+ return result.rows;
814
+ } catch (err) {
815
+ // A refusal is an answer: the table is not reachable by this caller at all.
816
+ return { blocked: err.message };
817
+ } finally {
818
+ await client.query('ROLLBACK');
819
+ }
820
+ }
821
+
822
+ function rowsOwnedBy(rows, owner, person) {
823
+ if (!Array.isArray(rows)) return 0;
824
+ return rows.filter((r) => String(r[owner]) === person).length;
825
+ }
826
+
827
+ /**
828
+ * A refusal is only an answer when it is the right refusal.
829
+ *
830
+ * "permission denied for table orders" means this caller cannot reach the
831
+ * table at all. That is the attack being defeated, and it is good news worth
832
+ * recording as a pass.
833
+ *
834
+ * Everything else - a schema the policy needs and the role cannot use, a
835
+ * function the policy calls that is not there, a timeout - means the rule was
836
+ * never evaluated. No verdict exists. Both come back as an error and return
837
+ * zero rows, and zero rows is exactly what a perfectly secured table returns,
838
+ * so telling them apart is the difference between "you are safe" and "I could
839
+ * not tell", which is the difference the whole product rests on.
840
+ */
841
+ function refusalMeans(message) {
842
+ // The multi-word kinds come first. Postgres says "permission denied for
843
+ // materialized view hits", and an alternation that tried `view` first would
844
+ // never reach it - so a matview nobody had granted was filed as untested
845
+ // rather than as the attack being beaten, and a correct app collected a
846
+ // warning it had not earned.
847
+ const denied = /permission denied for (materialized view|foreign table|partitioned table|table|relation|view|sequence)/i;
848
+ return denied.test(String(message)) ? 'unreachable' : 'untested';
849
+ }
850
+
851
+ /**
852
+ * The table whose rule looks itself up, or null.
853
+ *
854
+ * A rule on organization_members that asks organization_members who is a
855
+ * member has to be checked to be checked, and Postgres stops the request
856
+ * rather than loop. Found on a blind test: every signed-in read touching that
857
+ * table failed, the app was broken for everyone with an account, and the
858
+ * report listed it only as a reason something was not tested. It is a finding
859
+ * in its own right - and it names the table at fault, which is often not the
860
+ * one that was being read.
861
+ */
862
+ /**
863
+ * The other shape the same loop takes. When the rule reaches its own table
864
+ * through a helper function rather than directly, Postgres does not see a
865
+ * policy recursing - it runs out of stack - and the message names no table.
866
+ * Found on a blind test: every signed-in read failed with "stack depth limit
867
+ * exceeded", and the report filed each one only as untested.
868
+ */
869
+ function tooDeep(message) {
870
+ return /stack depth limit exceeded/i.test(String(message || ''));
871
+ }
872
+
873
+ function recursionIn(message) {
874
+ const found = /infinite recursion detected in policy for relation "([^"]+)"/i.exec(String(message || ''));
875
+ return found ? found[1] : null;
876
+ }
877
+
878
+ /**
879
+ * What each table gives away, and to whom.
880
+ *
881
+ * Two separate findings, because they are two different conversations with the
882
+ * person who has to fix it:
883
+ *
884
+ * exposed - a logged-out stranger can read the table. This is the one that
885
+ * ends up on a news site.
886
+ * crossed - a signed-in customer can read another customer's rows. Quieter,
887
+ * and the one that breaks trust with the people already paying.
888
+ */
889
+ async function impersonate(client, schema, tables) {
890
+ const findings = [];
891
+ const completed = [];
892
+ const blocked = [];
893
+ // Rules that looked themselves up, and which read ran into each one.
894
+ const looped = [];
895
+
896
+ /** Did this read produce a verdict, and if not, why not? */
897
+ const settle = (key, table, answer, as) => {
898
+ if (Array.isArray(answer)) {
899
+ completed.push(key);
900
+ return true;
901
+ }
902
+ if (refusalMeans(answer.blocked) === 'unreachable') {
903
+ // Refused outright. The attack ran and lost, which is the result we want
904
+ // for a table that is properly closed.
905
+ completed.push(key);
906
+ return false;
907
+ }
908
+ blocked.push({ table: table, key: key, why: as + ': ' + answer.blocked });
909
+ return false;
910
+ };
911
+
912
+ const parents = parentTables(tables);
913
+ for (const table of tables) {
914
+ const owner = ownerColumn(table);
915
+ const tenant = owner ? null : tenantKey(table, tables, parents);
916
+ const anon = await readAs(client, schema, table.name, 'anon', null);
917
+ const asA = await readAs(client, schema, table.name, 'authenticated', USER_A);
918
+
919
+ // Looked for on every table, owner or not: a signed-in read of a table
920
+ // with nobody's name on it fails just the same, and it is the same
921
+ // broken page.
922
+ for (const [answer, who] of [[anon, 'anyone'], [asA, 'signed-in']]) {
923
+ if (Array.isArray(answer)) continue;
924
+ const relation = recursionIn(answer.blocked);
925
+ // A loop with no table named is recorded with none; the scan works out
926
+ // which table's rule it is from the rules and helpers it has read.
927
+ if (relation || tooDeep(answer.blocked)) looped.push({ relation: relation || null, table: table.name, who: who });
928
+ }
929
+ // Only a read of the table itself that came back with a verdict, for both
930
+ // callers, shows its rules no longer loop. A read that failed for some
931
+ // other reason shows nothing either way.
932
+ const answered = (answer) => Array.isArray(answer) || refusalMeans(answer.blocked) === 'unreachable';
933
+ if (answered(anon) && answered(asA)) completed.push('recursive:' + table.name);
934
+
935
+ if (settle('exposed:' + table.name, table.name, anon, 'as a logged-out visitor') && anon.length > 0) {
936
+ findings.push({
937
+ kind: 'exposed',
938
+ table: table.name,
939
+ readable: anon.length,
940
+ columns: Object.keys(anon[0] || {}),
941
+ rlsEnabled: table.rlsEnabled,
942
+ isView: Boolean(table.isView),
943
+ // No column says whose a row is - no person, no team: it may be meant
944
+ // to be public, a price list or a blog (blind round 17, DEC-03).
945
+ nobodys: !owner && !tenant,
946
+ });
947
+ }
948
+
949
+ // Crossed is only ever looked for where a row says who it belongs to, so
950
+ // on a table with no owner there is no attack to record either way.
951
+ if (owner && settle('crossed:' + table.name, table.name, asA, 'as a signed-in customer')) {
952
+ const theirs = rowsOwnedBy(asA, owner, USER_B);
953
+ if (theirs > 0) {
954
+ findings.push({
955
+ kind: 'crossed',
956
+ table: table.name,
957
+ owner: owner,
958
+ readable: theirs,
959
+ columns: Object.keys(asA[0] || {}),
960
+ rlsEnabled: table.rlsEnabled,
961
+ });
962
+ }
963
+ }
964
+
965
+ // A table whose rows are an organization's, not a person's: its one row is
966
+ // the first person's organization's, and the second person - signed in,
967
+ // in an organization of their own - asks for it. What a logged-out
968
+ // stranger can already read is reported as exposed, and not twice.
969
+ if (tenant) {
970
+ const asB = await readAs(client, schema, table.name, 'authenticated', USER_B);
971
+ const open = Array.isArray(anon) && anon.length > 0;
972
+ if (settle('crossed:' + table.name, table.name, asB, 'as a signed-in user in another organization') &&
973
+ asB.length > 0 && !open) {
974
+ findings.push({
975
+ kind: 'crossed',
976
+ table: table.name,
977
+ owner: tenant,
978
+ tenant: true,
979
+ readable: asB.length,
980
+ columns: Object.keys(asB[0] || {}),
981
+ rlsEnabled: table.rlsEnabled,
982
+ });
983
+ }
984
+ }
985
+ }
986
+
987
+ return { findings: findings, completed: completed, blocked: blocked, looped: looped };
988
+ }
989
+
990
+ /** A stable shape for comparing one run against another. */
991
+ function summarise(result) {
992
+ const findings = Array.isArray(result) ? result : (result && result.findings) || [];
993
+ return findings
994
+ .map((f) => f.kind + ':' + f.table + ':' + f.readable)
995
+ .sort()
996
+ .join(' | ');
997
+ }
998
+
999
+ module.exports = {
1000
+ USER_A: USER_A,
1001
+ USER_B: USER_B,
1002
+ USER_C: USER_C,
1003
+ USER_D: USER_D,
1004
+ refusalMeans: refusalMeans,
1005
+ recursionIn: recursionIn,
1006
+ tooDeep: tooDeep,
1007
+ ownerColumn: ownerColumn,
1008
+ fitTo: fitTo,
1009
+ foreignKeys: foreignKeys,
1010
+ primaryKeyOf: primaryKeyOf,
1011
+ parentFor: parentFor,
1012
+ impliedKeys: impliedKeys,
1013
+ parentTables: parentTables,
1014
+ dependencyOrder: dependencyOrder,
1015
+ valueFor: valueFor,
1016
+ allowedByCheck: allowedByCheck,
1017
+ checksOn: checksOn,
1018
+ checkCandidates: checkCandidates,
1019
+ fittingValues: fittingValues,
1020
+ exactlyOne: exactlyOne,
1021
+ rowFor: rowFor,
1022
+ existingRow: existingRow,
1023
+ insertRow: insertRow,
1024
+ seed: seed,
1025
+ readAs: readAs,
1026
+ impersonate: impersonate,
1027
+ summarise: summarise,
1028
+ };