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/finding.js CHANGED
@@ -1,1612 +1,1715 @@
1
- // Turning what the attack saw into something a person can act on.
2
- //
3
- // The detection is the easy tenth. A builder who cannot read code does not buy
4
- // "crossed:customers:1" - they buy knowing their customer list is open, seeing
5
- // it happen, and having one thing to paste that closes it.
6
- //
7
- // The rule everything here follows: never say more than was actually seen. If
8
- // the attack read a table with an email column in it, say email. If it did not,
9
- // do not reach for "personal details" because it sounds more urgent. A report
10
- // that overstates once is a report nobody trusts again, and trust is the entire
11
- // product - the re-check at the end is worth exactly as much as the first
12
- // report was honest.
13
-
14
- /* --------------------------------------------------------------------------
15
- What was in the table.
16
- -------------------------------------------------------------------------- */
17
-
18
- // Things that identify a person. Matched on whole words so that `company_name`
19
- // counts and `renamed_at` does not.
20
- const IDENTITY = [
21
- [/\b(email|e_mail|mail)\b/i, 'email addresses'],
22
- [/\b(phone|mobile|contact_number|telephone)\b/i, 'phone numbers'],
23
- // `\b` treats "_" as part of a word, so display_name never matched `name` -
24
- // found on a blind test, where a public profiles table was reported as
25
- // holding nothing in particular. The compound names are listed instead.
26
- [/\b(full_name|first_name|last_name|given_name|surname|name|display_name|username|user_name|nickname|screen_name)\b/i, 'names'],
27
- [/\b(address|street|city|postcode|zip|pincode)\b/i, 'addresses'],
28
- [/\b(dob|date_of_birth|birth_date|birthday)\b/i, 'dates of birth'],
29
- ];
30
-
31
- // Things that are worse than identifying - they let somebody act as the person,
32
- // or take money.
33
- const SECRETS = [
34
- // `\b` counts "_" as a letter, so these missed every compound name - found
35
- // on a blind test: integration_token, access_token and signing_secret, all
36
- // readable by anyone, and the report named none of them. A name that ENDS
37
- // in the secret word is the secret; token_expires_at is only about one.
38
- // Singular only: `tokens` and `input_tokens` count what an AI call used -
39
- // found on the next blind test, where api_usage.tokens (an integer) was
40
- // named as a secret.
41
- [/\b(password|passwd|pass_hash|password_hash)\b|(^|_)(password|passwd|pwd|password_hash)$/i, 'passwords'],
42
- [/\b(token|api_key|apikey|secret|private_key|access_key)\b|(^|_)(token|secret|(api|private|access|secret|signing|service|encryption)_?key)$/i, 'access tokens and secrets'],
43
- [/\b(card|card_number|cvv|iban|account_number|upi)\b/i, 'payment details'],
44
- // A code that grants something: whoever reads a moderator_code can become a
45
- // moderator. Found on a blind test, added straight in production and readable
46
- // by anyone - reported only as "2 rows".
47
- [/^(?:\w+_)?(moderator|invite|invitation|access|reset|verification|recovery|admin|join|auth|login|magic|security|otp)_code$|^(otp|pin)$/i, 'secret codes'],
48
- ];
49
-
50
- // Who holds power in the app. Not a secret in itself, but a list of exactly
51
- // which accounts are worth attacking.
52
- const PRIVILEGE = [/^(is_admin|is_superuser|is_super_admin|is_staff|is_moderator|is_owner|role|roles|permissions)$/i, 'who is an admin'];
53
-
54
- const MONEY = [/\b(amount|total|price|balance|salary|revenue|invoice)\b/i, 'amounts of money'];
55
-
56
- // Each kind named with the columns that showed it: "secret codes
57
- // (moderator_code)" can be found in the table; "secret codes" has to be
58
- // looked for.
59
- function matched(columns, table) {
60
- const names = (columns || []).map(String);
61
- const found = [];
62
- for (const [pattern, label] of table) {
63
- const hits = names.filter((column) => pattern.test(column));
64
- if (hits.length) found.push(label + ' (' + hits.join(', ') + ')');
65
- }
66
- return found;
67
- }
68
-
69
- /** What a table holds, in the words a person would use. */
70
- function readContents(columns) {
71
- const identity = matched(columns, IDENTITY);
72
- const secrets = matched(columns, SECRETS);
73
- const money = matched(columns, [MONEY]);
74
- const privilege = matched(columns, [PRIVILEGE]);
75
- return { identity: identity, secrets: secrets, money: money, privilege: privilege };
76
- }
77
-
78
- /** Everything worth naming, worst first. */
79
- function heldIn(contents) {
80
- return contents.secrets.concat(contents.identity, contents.privilege || [], contents.money);
81
- }
82
-
83
- /** A list, written the way a person writes one. */
84
- function listOf(items) {
85
- const list = items.filter(Boolean);
86
- if (!list.length) return '';
87
- if (list.length === 1) return list[0];
88
- return list.slice(0, -1).join(', ') + ' and ' + list[list.length - 1];
89
- }
90
-
91
- /** One of a table's things: workspaces -> workspace, companies -> company. */
92
- /** A role, quoted, with its article: a "viewer", an "analyst". */
93
- function aRole(role) {
94
- return (/^[aeiou]/i.test(String(role)) ? 'an "' : 'a "') + role + '"';
95
- }
96
-
97
- /** The first letter made a capital, for the start of a sentence. */
98
- function capital(text) {
99
- return text.charAt(0).toUpperCase() + text.slice(1);
100
- }
101
-
102
- /** The same, with its article: a workspace, an organization. */
103
- function aOne(table) {
104
- const one = oneOf(table);
105
- return (/^[aeiou]/i.test(one) ? 'an ' : 'a ') + one;
106
- }
107
-
108
- function oneOf(table) {
109
- return String(table || 'team').replace(/ies$/, 'y').replace(/s$/, '');
110
- }
111
-
112
- /* --------------------------------------------------------------------------
113
- How bad it is.
114
- -------------------------------------------------------------------------- */
115
-
116
- /**
117
- * Severity, decided by what could be read rather than by how it was read.
118
- *
119
- * Only two levels on purpose. A scale of five invites the reader to skim past
120
- * the bottom three, and everything the night shift reports is something that
121
- * should be fixed - there is no such thing as a low finding here.
122
- */
123
- function severityOf(finding, contents) {
124
- if (finding.kind === 'recursive') {
125
- // Nothing leaks and nothing is written: the request fails. Serious,
126
- // because the app is broken for whoever it fails for, but not a break-in.
127
- return 'HIGH';
128
- }
129
- // A member who can make themselves the owner: never the design, and the
130
- // owner's reach is everything - the one role finding that is confirmed.
131
- if (finding.kind === 'role' && finding.promote) return 'CRITICAL';
132
- if (finding.kind === 'role' || finding.kind === 'teamread' || finding.kind === 'bucket') {
133
- // Something to check, never a confirmed break: whether a viewer may edit,
134
- // or a bucket may be public, is the owner's decision. So never CRITICAL.
135
- return 'HIGH';
136
- }
137
- if (finding.kind === 'conflict' || finding.kind === 'searchpath' || finding.kind === 'nullcheck' || finding.kind === 'noinsert') {
138
- // Read from how a function or a column is declared. A refused upsert
139
- // breaks the app for whoever calls it; an open search_path and an empty
140
- // value are for the owner to weigh. None of them hands a row to anybody.
141
- return 'HIGH';
142
- }
143
- if (finding.kind === 'privileged') {
144
- // Not proven, so never CRITICAL: this is a reach that exists, not a break
145
- // that was demonstrated. A function anon can call that also writes is worth
146
- // more attention than one that only reads, but both stay HIGH and both
147
- // carry "verification required" - the person has to say whether the anon
148
- // call was intended.
149
- return 'HIGH';
150
- }
151
- if (finding.kind === 'orphaned') {
152
- // Nothing is exposed and nothing is destroyed, so this is not the same
153
- // order of thing as a table anyone can empty. It stays serious because
154
- // the data it strands is data somebody asked to have deleted.
155
- return 'HIGH';
156
- }
157
- if (finding.kind === 'writable') {
158
- // Deleting and rewriting are unrecoverable in a way that reading is not:
159
- // a leak is bad, but a customer table somebody emptied is gone. Being able
160
- // only to add rows is serious and not the same thing.
161
- const canDestroy = (finding.can || []).some((what) => what === 'delete' || what === 'change');
162
- return canDestroy ? 'CRITICAL' : 'HIGH';
163
- }
164
- if (finding.kind === 'duplicated') {
165
- // Judged by what the duplicate lets somebody do, not by what else sits in
166
- // the table. A second row holding the same session token is critical even
167
- // if the table has nothing personal in it at all.
168
- if (finding.expectation === 'credential') return 'CRITICAL';
169
- // Critical only while this column could be the login itself. When the
170
- // database has Supabase's auth.users, sign-in goes through that table,
171
- // which keeps every email unique - so a duplicate here is a copy that
172
- // disagrees with itself, not two accounts sharing one login. Found on a
173
- // real app: the report said "two accounts can answer to the same login"
174
- // about a profiles table the login never read.
175
- if (finding.expectation === 'identity') return finding.loginElsewhere ? 'HIGH' : 'CRITICAL';
176
- return 'HIGH';
177
- }
178
- if (contents.secrets.length) return 'CRITICAL';
179
- if (contents.identity.length) return 'CRITICAL';
180
- return 'HIGH';
181
- }
182
-
183
- /* --------------------------------------------------------------------------
184
- Saying it.
185
- -------------------------------------------------------------------------- */
186
-
187
- /**
188
- * Why the door was open - and these two are genuinely different problems.
189
- *
190
- * Row level security switched off is an oversight. Switched on with a policy
191
- * that lets everybody through is worse, because the dashboard shows the table
192
- * as protected and the person has already been told they are safe. Saying which
193
- * one it is saves them looking in the wrong place.
194
- */
195
- function causeOf(finding) {
196
- if (finding.kind === 'recursive') {
197
- return {
198
- short: 'a rule on it looks the same table up again',
199
- long:
200
- 'A row level security rule on this table checks something by reading ' +
201
- 'this same table - usually "is this person a member?" asked of the ' +
202
- 'members table itself. Reading the table means applying the rule, and ' +
203
- 'applying the rule means reading the table, so Postgres stops the ' +
204
- 'request with an error instead of going round forever. Any other ' +
205
- 'table whose rule asks this one fails the same way.',
206
- };
207
- }
208
- if (finding.kind === 'orphaned') {
209
- return {
210
- short: 'nothing in the database ties the two tables together',
211
- long:
212
- 'There is no foreign key between these two tables, so the database has '+
213
- 'no idea they are related. It will not refuse a row that points at '+
214
- 'nothing, it will not stop somebody being deleted while their rows are '+
215
- 'still here, and it will never find these rows again afterwards.',
216
- };
217
- }
218
- if (finding.kind === 'writable') {
219
- if (finding.rlsEnabled) {
220
- // Which rule let it through, from what is actually on the table rather
221
- // than from an assumption.
222
- //
223
- // This branch used to say, of every writable table with row level
224
- // security on, that its rule "covers every command rather than only
225
- // reading". Measured against a table whose only policy was written FOR
226
- // INSERT, that sentence was false twice over: the rule covered one
227
- // command, and there was no rule letting anybody read at all. The
228
- // finding was proved; the reason was invented. A report that overstates
229
- // once is believed never again, so the reason is now read, not guessed.
230
- const rules = (finding.rules || []).map((rule) => String(rule).toUpperCase());
231
- const WRITTEN_FOR = { add: 'INSERT', change: 'UPDATE', 'delete': 'DELETE' };
232
- const IN_WORDS = { INSERT: 'adding rows', UPDATE: 'changing rows', DELETE: 'deleting rows' };
233
- const named = [];
234
- for (const move of finding.can || []) {
235
- const command = WRITTEN_FOR[move];
236
- if (command && rules.includes(command) && !named.includes(command)) named.push(command);
237
- }
238
-
239
- if (rules.includes('ALL')) {
240
- return {
241
- short: 'the rule on it covers every command, not only reading',
242
- long:
243
- 'The table has row level security switched on, but the rule attached ' +
244
- 'to it is written FOR ALL - so the same rule that decides who may ' +
245
- 'see a row also decides who may add, change and delete one, and it ' +
246
- 'is letting this through.',
247
- };
248
- }
249
-
250
- if (named.length) {
251
- return {
252
- short: 'the rule you wrote for ' + listOf(named.map((c) => IN_WORDS[c])) +
253
- ' lets everybody through',
254
- long:
255
- 'The table has row level security switched on, and there is a rule on ' +
256
- 'it written for ' + listOf(named.map((c) => IN_WORDS[c])) + '. That ' +
257
- 'rule is the one letting this through: it does not check who is ' +
258
- 'asking, so it accepts the request from a stranger exactly as it ' +
259
- 'would from the person the row belongs to.',
260
- };
261
- }
262
-
263
- // Row level security on, and no permissive rule that names this write.
264
- // It should not have been possible, and saying which rule did it would
265
- // be inventing one. What is certain is what was done, and that is all
266
- // this says.
267
- return {
268
- short: 'row level security is on, and something let this through anyway',
269
- long:
270
- 'The table has row level security switched on, and none of the rules on ' +
271
- 'it are written for this kind of write - so this should have been ' +
272
- 'refused and was not. Check whether the role your app connects as owns ' +
273
- 'the table or has BYPASSRLS, because either of those goes round every ' +
274
- 'rule you have written.',
275
- };
276
- }
277
- return {
278
- short: 'row level security was never switched on',
279
- long:
280
- 'Row level security has never been switched on for this table. Supabase ' +
281
- 'grants insert, update and delete on the public schema by default, and ' +
282
- 'row level security is what takes them back - so until it is on, those ' +
283
- 'permissions are simply in force.',
284
- };
285
- }
286
- if (finding.isView) {
287
- // The one people are caught by, because everything looks right. The table
288
- // is protected, the policy is correct, the dashboard is green - and the
289
- // view hands the rows out anyway.
290
- return {
291
- short: 'a view reads with its creator\'s rights, not the visitor\'s',
292
- long:
293
- 'This is a view, and a view runs as whoever created it unless it is ' +
294
- 'told otherwise. So any row level security on the tables underneath is ' +
295
- 'checked against the creator, not against the person asking - and the ' +
296
- 'rules you wrote on those tables do not apply here at all.',
297
- };
298
- }
299
- if (finding.kind === 'duplicated') {
300
- return {
301
- short: 'nothing in the database makes it unique',
302
- long:
303
- 'There is no unique constraint and no unique index on this column, so ' +
304
- 'the database has no way to refuse the second one. Checking in your app ' +
305
- 'code before inserting does not close this: between the check and the ' +
306
- 'insert, the other request has already been accepted.',
307
- };
308
- }
309
- if (finding.rlsEnabled) {
310
- return {
311
- short: 'the rule that guards it lets everybody through',
312
- long:
313
- 'The table has row level security switched on, so it looks protected, ' +
314
- 'but the rule attached to it allows every request. That is why nothing ' +
315
- 'in your dashboard flags it.',
316
- };
317
- }
318
- return {
319
- short: 'it has no protection switched on at all',
320
- long:
321
- 'Row level security has never been switched on for this table, so every ' +
322
- 'rule you might have written elsewhere does not apply to it.',
323
- };
324
- }
325
-
326
- /** What a caller could do, written the way a person would say it. */
327
- const WRITE_WORDS = { add: 'add rows to', change: 'change rows in', delete: 'delete rows from' };
328
-
329
- /**
330
- * The error a looping rule produces, as Postgres words it. A rule that reads
331
- * its own table directly is caught as recursion and named; one that reaches
332
- * it through a helper function runs out of stack, and Postgres names nothing.
333
- */
334
- function loopError(finding) {
335
- return finding.deep
336
- ? '"stack depth limit exceeded"'
337
- : '"infinite recursion detected in policy for relation ' + finding.table + '"';
338
- }
339
-
340
- /** Who a looping rule fails for, said the way the headline and body need it. */
341
- function loopVictims(finding) {
342
- const callers = finding.callers || [];
343
- if (callers.includes('anyone') && callers.includes('signed-in')) return 'everyone, signed in or not,';
344
- if (callers.includes('anyone')) return 'visitors who are not logged in';
345
- return 'signed-in users';
346
- }
347
-
348
- function headlineFor(finding) {
349
- if (finding.kind === 'recursive') {
350
- return 'A rule on your ' + finding.table + ' table refers to itself, so ' + loopVictims(finding) +
351
- ' get an error instead of data.';
352
- }
353
- if (finding.kind === 'orphaned') {
354
- return 'Your ' + finding.table + ' table can point at a ' +
355
- finding.parent.replace(/s$/, '') + ' that does not exist.';
356
- }
357
- if (finding.kind === 'writable') {
358
- // Worst first, because the headline is often all that gets read.
359
- const order = ['delete', 'change', 'add'];
360
- const does = order.filter((what) => (finding.can || []).includes(what)).map((what) => WRITE_WORDS[what]);
361
- return (finding.who === 'anyone' ? 'Anyone' : 'Any signed-in customer') + ' can ' +
362
- listOf(does) + ' your ' + finding.table + ' table.';
363
- }
364
- if (finding.kind === 'duplicated') {
365
- return 'Your ' + finding.table + ' table lets the same ' + finding.column + ' exist twice.';
366
- }
367
- if (finding.kind === 'privileged') {
368
- return 'Anyone on the internet can call your ' + finding.fn + ' function, and it runs with full rights.';
369
- }
370
- if (finding.kind === 'searchpath') {
371
- return 'Your ' + finding.fn + ' function runs with full rights but does not pin its search_path.';
372
- }
373
- if (finding.kind === 'conflict') {
374
- return 'Your ' + finding.fn + ' function fails every time it saves: Postgres refuses its ON CONFLICT.';
375
- }
376
- if (finding.kind === 'nullcheck') {
377
- return 'Your ' + finding.table + ' table accepts rows with no ' + finding.column + ' at all.';
378
- }
379
- if (finding.kind === 'noinsert') {
380
- return 'Nobody can add a row to your ' + finding.table + ' table: its rule for adding rows checks nothing, and Postgres reads that as no.';
381
- }
382
- if (finding.kind === 'role' && finding.takeover) {
383
- return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can take another member\'s place in your ' +
384
- finding.table + ' table - and with it their role.';
385
- }
386
- if (finding.kind === 'role' && finding.movein) {
387
- return 'Anyone signed in can make themselves ' + aRole(finding.toRole) + ' of any ' + oneOf(finding.parent) +
388
- ' by moving their own row in your ' + finding.table + ' table into it.';
389
- }
390
- if (finding.kind === 'role' && finding.promote) {
391
- return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can make themselves ' + aRole(finding.toRole) +
392
- ' by changing their own row in your ' + finding.table + ' table.';
393
- }
394
- if (finding.kind === 'role') {
395
- const order = ['delete', 'change', 'add'];
396
- const does = order.filter((what) => (finding.can || []).includes(what)).map((what) => WRITE_WORDS[what]);
397
- return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can ' + listOf(does) + ' your ' +
398
- finding.table + ' table.';
399
- }
400
- if (finding.kind === 'teamread') {
401
- return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can read your ' + finding.table +
402
- ' table, which holds ' + listOf(finding.secrets || []) + '.';
403
- }
404
- if (finding.kind === 'bucket' && finding.noRule && (finding.writers || []).length) {
405
- return 'Your ' + finding.table + ' storage bucket is public, though only signed-in members may put files in it.';
406
- }
407
- if (finding.kind === 'bucket' && finding.noRule) {
408
- return 'Your ' + finding.table + ' storage bucket is public, and its name says its files are not for everyone.';
409
- }
410
- if (finding.kind === 'bucket') {
411
- return 'Your ' + finding.table + ' storage bucket is public, but a rule says only some people may read it.';
412
- }
413
- if (finding.kind === 'exposed') {
414
- return 'Your ' + finding.table + ' ' + (finding.isView ? 'view' : 'table') + ' can be read by anyone.';
415
- }
416
- // The table name goes in front of the sentence rather than inside it. Put
417
- // inside, a table called `customers` produced "One customer can read another
418
- // customer's customers", which is the kind of line that loses a reader.
419
- if (finding.asRole) {
420
- return capital(aRole(finding.asRole)) + ' of any ' + oneOf(finding.parent) + ' can read every other ' + oneOf(finding.parent) +
421
- '\'s rows in your ' + finding.table + ' table.';
422
- }
423
- if (finding.tenant) return 'Your ' + finding.table + ' table lets a signed-in user read another organization\'s rows.';
424
- return 'Your ' + finding.table + ' table lets one customer read another one\'s rows.';
425
- }
426
-
427
- /** What a duplicate of this particular column actually costs the person. */
428
- const COST_OF_A_DUPLICATE = {
429
- credential: 'A credential that matches more than one row means one secret opens more than one account.',
430
- identity: 'Two accounts can answer to the same login, and a password reset has to guess which one to send.',
431
- code: 'A one-time code that can exist twice can be redeemed twice.',
432
- };
433
-
434
- // Said instead of the login sentence when sign-in happens in auth.users.
435
- const COST_OF_A_COPIED_IDENTITY =
436
- 'Signing in is not affected: your app signs people in through auth.users, ' +
437
- 'which keeps each email unique. But anything that looks a person up by this ' +
438
- 'column can get two rows back and pick the wrong one.';
439
-
440
- function costOf(finding) {
441
- if (finding.expectation === 'identity' && finding.loginElsewhere) return COST_OF_A_COPIED_IDENTITY;
442
- return COST_OF_A_DUPLICATE[finding.expectation] || '';
443
- }
444
-
445
- function bodyFor(finding, contents) {
446
- const holds = listOf(heldIn(contents));
447
- const rowWord = finding.readable === 1 ? 'row' : 'rows';
448
-
449
- if (finding.kind === 'recursive') {
450
- // Said as what happened to the requests, and then what it cost this
451
- // report: the tables behind the error could not be tested for leaks.
452
- const reads = finding.reads || [];
453
- const callers = finding.callers || [];
454
- const as = [];
455
- if (callers.includes('anyone')) as.push('a visitor who is not logged in');
456
- if (callers.includes('signed-in')) as.push('a signed-in user');
457
- const one = reads.length === 1;
458
- return (
459
- 'Using ' + listOf(reads) + ' on a copy of your app as ' + as.join(' and as ') +
460
- ', every request was refused with ' + loopError(finding) +
461
- (finding.deep ? ' - a rule on ' + finding.table + ' calls a helper that reads ' + finding.table + ' again, which runs the rule again' : '') +
462
- '. Every page in your app that uses ' + (one ? 'that table' : 'those tables') +
463
- ' fails the same way for ' + loopVictims(finding).replace(/,$/, '') + '. It also means I could ' +
464
- 'not test ' + (one ? 'it' : 'them') + ' for leaks - those attacks are listed at the end.'
465
- );
466
- }
467
-
468
- if (finding.kind === 'orphaned') {
469
- // Said as the thing a person will actually meet: a customer asks to be
470
- // deleted, and their rows are still here afterwards.
471
- return (
472
- 'I added a row to ' + finding.table + ' naming a ' +
473
- finding.parent.replace(/s$/, '') + " that is not in your " + finding.parent +
474
- ' table, and it was accepted. So when one of them is deleted, the rows here ' +
475
- 'stay behind pointing at nobody - and a signup or checkout that stops halfway ' +
476
- 'leaves the same thing. The row I added was undone straight away.'
477
- );
478
- }
479
-
480
- if (finding.kind === 'writable') {
481
- const holds = listOf(heldIn(contents));
482
- const who = finding.who === 'anyone'
483
- ? 'Without logging in and without an account, I '
484
- : 'Signed in as one of your customers, I ';
485
- const did = [];
486
- if ((finding.can || []).includes('add')) did.push('added a row');
487
- if ((finding.can || []).includes('change')) {
488
- did.push('changed ' + (finding.changed.change || 1) + " of another customer's rows");
489
- }
490
- if ((finding.can || []).includes('delete')) {
491
- did.push('deleted ' + (finding.changed.delete || 1) + " of another customer's rows");
492
- }
493
- // Said immediately, because "I deleted your rows" is a sentence that stops
494
- // somebody reading, and they need the next line more than the first.
495
- return who + listOf(did) + ' on a copy of your app' +
496
- (holds ? ', in a table holding ' + holds : '') +
497
- '. Every one of those was undone straight away - nothing on the copy was ' +
498
- 'kept, and your live app was never touched.';
499
- }
500
-
501
- if (finding.kind === 'role' && finding.takeover) {
502
- // Not a matter of taste like a viewer who may edit: taking somebody
503
- // else's membership is never what a role is for.
504
- const place = oneOf(finding.parent);
505
- return (
506
- 'I put one of my fake people into the other one\'s ' + place + ' as ' + aRole(finding.who) +
507
- ' in ' + finding.via + '.' + finding.roleColumn + ' and, signed in as them, changed ' +
508
- finding.via + '.' + finding.personColumn + ' on the other person\'s own membership row to their own id - so ' +
509
- 'they held that member\'s place and role, on a copy of your app. Before joining, they could not. It was ' +
510
- 'undone straight away. A member taking over another member\'s row is never something a role should be able ' +
511
- 'to do, even where members may edit some of a membership.'
512
- );
513
- }
514
-
515
- if (finding.kind === 'role' && finding.movein) {
516
- // Never the design: nobody was invited, and the team they moved into is
517
- // somebody else's.
518
- const place = oneOf(finding.parent);
519
- return (
520
- 'Signed in as one of my fake people - not a member of the other one\'s ' + place + ', never invited - I ' +
521
- 'changed their own row in ' + finding.via + ': ' + listOf((finding.moved || []).map((c) => finding.via + '.' + c)) +
522
- ' to the other ' + place + ', and ' + finding.via + '.' + finding.roleColumn + ' to "' + finding.toRole +
523
- '" - on a copy of your app. It went through, and was undone straight away. Anybody who can sign up and has a ' +
524
- place + ' of their own can do this to every ' + place + ' whose id they learn, and from then on has every right ' +
525
- aRole(finding.toRole) + ' has there.'
526
- );
527
- }
528
- if (finding.kind === 'role' && finding.promote) {
529
- // Never the design: a member who can raise their own role has every
530
- // right the top role has, in every table that trusts the role.
531
- const place = oneOf(finding.parent);
532
- return (
533
- 'I put one of my fake people into the other one\'s ' + place + ' as ' + aRole(finding.who) +
534
- ' in ' + finding.via + '.' + finding.roleColumn + ' and, signed in as them, changed ' + finding.via + '.' +
535
- finding.roleColumn + ' on their own membership row to "' + finding.toRole + '" - on a copy of your app. It was ' +
536
- 'undone straight away. From then on they would have every right ' + aRole(finding.toRole) + ' has, in every ' +
537
- 'table and function that trusts that role.'
538
- );
539
- }
540
-
541
- if (finding.kind === 'role') {
542
- // Said as what was done, and then whose decision it is: a viewer who may
543
- // edit is a hole in one app and the design of another.
544
- const did = [];
545
- const rows = (n) => n + (n === 1 ? ' row' : ' rows');
546
- if ((finding.can || []).includes('add')) did.push('added ' + rows((finding.changed || {}).add || 1));
547
- if ((finding.can || []).includes('change')) did.push('changed ' + rows((finding.changed || {}).change || 1));
548
- if ((finding.can || []).includes('delete')) did.push('deleted ' + rows((finding.changed || {}).delete || 1));
549
- const place = oneOf(finding.parent);
550
- // The roles below it that were tried first and could not, when there are
551
- // any: a role in the middle is named because the ones under it held.
552
- const below = (finding.below || []).map((r) => '"' + r + '"');
553
- return (
554
- 'I put one of my fake people into the other one\'s ' + place + ' as ' + aRole(finding.who) +
555
- ' - ' + (below.length ? 'a role' : 'the lowest role') + ' in ' + finding.via + '.' + finding.roleColumn +
556
- ' - and, signed in as them, I ' + listOf(did) + ' in ' + finding.table + ' for that ' + place +
557
- ', on a copy of your app. Before joining, they could not.' +
558
- (below.length ? ' As ' + listOf(below).replace(/ and /, ' or ') + ' they could not either, and no rule on this ' +
559
- 'table lists ' + aRole(finding.who).replace(/^an? /, '') + ' among the roles allowed to make that change.' : '') +
560
- ' Every one of those was undone straight away. Whether ' + aRole(finding.who) + ' may do this is your ' +
561
- 'decision: if it is meant to, nothing needs fixing; if not, the rules on this table let them do more.'
562
- );
563
- }
564
-
565
- if (finding.kind === 'teamread') {
566
- // Said as what was read and by whom, and which roles below it could not:
567
- // a dispatcher seeing a key may be the design, a viewer rarely is.
568
- const place = oneOf(finding.parent);
569
- const n = finding.readable || 1;
570
- const below = (finding.below || []).map((r) => '"' + r + '"');
571
- return (
572
- 'I put one of my fake people into the other one\'s ' + place + ' as ' + aRole(finding.who) + ' and, signed ' +
573
- 'in as them, read ' + n + (n === 1 ? ' row' : ' rows') + ' of ' + finding.table + ' for that ' + place +
574
- ', on a copy of your app - including ' + listOf(finding.secrets || []) + '. Before joining, they could not.' +
575
- (below.length ? ' As ' + listOf(below).replace(/ and /, ' or ') + ' they could not read it either.' : '') +
576
- ' Nothing was changed. Whether ' + aRole(finding.who) + ' should see these is your decision: if not, a read ' +
577
- 'rule on this table lets them.'
578
- );
579
- }
580
-
581
- if (finding.kind === 'bucket' && finding.noRule) {
582
- return (
583
- 'In Supabase Storage the bucket "' + finding.bucket + '" is set to public, so anyone with a ' +
584
- 'file\'s link can download it without logging in - the rules on storage.objects are not asked. ' +
585
- ((finding.writers || []).length
586
- ? 'No rule says who may read it, but ' + listOf((finding.writers || []).map((r) => '"' + r + '"')) +
587
- ' let only signed-in members put files in or take them out - so the app treats these files as its ' +
588
- 'members\' own, while anyone holding a link can download them. '
589
- : 'No rule says who may read it, but its name says what it holds, and that does not sound like ' +
590
- 'something for everyone. ') +
591
- 'I only read the bucket\'s setting and its rules; I did not open or list ' +
592
- 'any file. Whether it should be public is yours to say.'
593
- );
594
- }
595
- if (finding.kind === 'bucket') {
596
- // Said as what was read, never as a file taken: no file was opened.
597
- return (
598
- 'In Supabase Storage the bucket "' + finding.bucket + '" is set to public, so anyone with a ' +
599
- 'file\'s link can download it without logging in - the rules on storage.objects are not asked. ' +
600
- 'You also wrote ' + ((finding.rules || []).length === 1 ? 'a rule' : 'rules') + ' saying who may read it (' +
601
- listOf(finding.rules || []) + '), so it was probably meant to be private. I only read the ' +
602
- 'bucket\'s setting and its rules; I did not open or list any file. Whether it should be public ' +
603
- 'is yours to say.'
604
- );
605
- }
606
-
607
- if (finding.kind === 'searchpath') {
608
- return (
609
- 'This function is SECURITY DEFINER, so it runs as whoever created it and the row level ' +
610
- 'security on the tables it touches is skipped for the caller - and ' +
611
- (finding.anon ? 'anyone, even without an account,' : 'every signed-in user') + ' can call it. ' +
612
- 'It does not set its search_path, so every table or function inside it written without a ' +
613
- 'schema (scans rather than public.scans) is looked up wherever the caller\'s path points ' +
614
- 'first. Somebody able to put a table or function earlier on that path would make it use ' +
615
- 'theirs, with the owner\'s rights. I did not call it; this is read from how it is declared.'
616
- );
617
- }
618
- if (finding.kind === 'conflict') {
619
- return (
620
- finding.fn + ' inserts into ' + finding.target + ' with ON CONFLICT (' + finding.on + '). ' +
621
- 'Postgres needs a unique index or constraint on exactly those columns to know what a ' +
622
- 'conflict is, and ' + finding.target + ' has none it can use, so it refuses the statement - ' +
623
- 'the function errors on every call instead of saving. ' +
624
- (finding.partialWhere
625
- ? 'There is a unique index on those columns, but it is partial (WHERE ' + finding.partialWhere +
626
- '), and a partial index is only used when the ON CONFLICT repeats its WHERE. '
627
- : '') +
628
- 'I did not call the function: I asked Postgres to plan that one statement on the copy, and ' +
629
- 'it answered "there is no unique or exclusion constraint matching the ON CONFLICT specification".'
630
- );
631
- }
632
- if (finding.kind === 'nullcheck') {
633
- return (
634
- finding.column + ' has a CHECK that allows only ' +
635
- listOf((finding.allowed || []).map((v) => '\'' + v + '\'')) + ' - but a CHECK only refuses a ' +
636
- 'value it can show is wrong, and an empty value (NULL) is never shown wrong, so it passes. ' +
637
- 'The column is not NOT NULL and has no default, so a row saved without a ' + finding.column +
638
- ' is kept with none, and anything that shows or sorts by it has to cope. I did not insert ' +
639
- 'anything; this is read from how the column is declared.'
640
- );
641
- }
642
- if (finding.kind === 'noinsert') {
643
- return (
644
- 'Row level security is on for ' + finding.table + ', and the only ' + ((finding.rules || []).length === 1 ? 'rule' : 'rules') +
645
- ' for adding rows (' + listOf((finding.rules || []).map((r) => '"' + r + '"')) + ') ' +
646
- ((finding.rules || []).length === 1 ? 'has' : 'have') + ' neither USING nor WITH CHECK. A rule with no ' +
647
- 'expression is not "allow everything": Postgres treats it as false, so every INSERT - from the app, ' +
648
- 'and from any function that runs as the user - is refused with "new row violates row-level security ' +
649
- 'policy". If your app adds ' + finding.table + ' rows, that feature is broken for everyone. This is read ' +
650
- 'from how the rule is written; I did not insert anything.'
651
- );
652
- }
653
- if (finding.kind === 'privileged') {
654
- // Said as what is true, not as what was done - nothing was executed. The
655
- // reach is real; whether it is a mistake is the person's to say, so the
656
- // wording asks rather than accuses.
657
- return (
658
- 'This function is SECURITY DEFINER, so it runs as whoever created it, not ' +
659
- 'as the visitor calling it - which means the row level security on the tables ' +
660
- 'it touches is checked against the owner and skipped for the caller. It is ' +
661
- 'granted to anon, the role a visitor with no account uses, so anyone on the ' +
662
- 'internet can call it' +
663
- (finding.writes ? ' - and it writes to your data, so a caller can change rows the rules would otherwise protect.'
664
- : ', reaching whatever it reads past the rules on those tables.') +
665
- ' I did not call it: whether an open function like this is intended is ' +
666
- 'something only you can confirm.' +
667
- (finding.nullSlips
668
- ? ' Its first lines do check the caller\'s role - but with NOT IN or <>, and somebody with no role ' +
669
- 'there gets NULL back, and NULL NOT IN (...) is not true: the IF never raises, so a signed-in ' +
670
- 'stranger gets through as if they had passed.'
671
- : '') +
672
- (finding.hasFixedSearchPath ? ''
673
- : ' It also does not pin its search_path, so a caller can point the names ' +
674
- 'inside it at their own objects.')
675
- );
676
- }
677
- if (finding.kind === 'duplicated') {
678
- // Said as what was done, not as what it implies. Two connections, one
679
- // moment, both accepted, and here is the count afterwards.
680
- return (
681
- 'I opened two connections to a copy of your app and inserted the same ' +
682
- finding.column + ' from both at the same moment. Both were accepted, so there are ' +
683
- 'now ' + finding.copies + ' rows holding the identical value. ' + costOf(finding)
684
- ).trim();
685
- }
686
-
687
- if (finding.kind === 'exposed') {
688
- // Two different proofs, said as two different things. The copy-based scan
689
- // signed in with the anon key and pulled the rows out; the outside check
690
- // (outside.js) asked the live app for the count alone and never pulled a
691
- // row, so it may not claim it did.
692
- if (finding.countOnly) {
693
- const many = finding.readable === 1 ? 'is 1 row' : 'are ' + finding.readable + ' rows';
694
- return (
695
- 'Anyone on the internet, without logging in and without an account, can read ' +
696
- 'this table. I asked your live app as a stranger and it let me - there ' + many +
697
- ' in it. I asked only for the count, so I never pulled a row out; a real ' +
698
- 'attacker would have taken them.'
699
- );
700
- }
701
- return (
702
- 'Anyone on the internet, without logging in and without an account, can read ' +
703
- // Read back the next morning, "just now" would be untrue: it was the
704
- // nightly run, hours earlier.
705
- 'this table. ' + (finding.fromNight ? 'The nightly run did it on a copy and got back ' : 'I did it myself just now and got back ') +
706
- finding.readable + ' ' + rowWord +
707
- (holds ? ', including ' + holds : '') + '.'
708
- );
709
- }
710
- if (finding.asRole) {
711
- // Blind round 15: a role check in a read rule that was never tied to the
712
- // row's own organization.
713
- return (
714
- 'I made one of my fake people ' + aRole(finding.asRole) + ' of their own ' + oneOf(finding.parent) + ' (' + finding.via + '.' +
715
- finding.roleColumn + ') and, signed in as them, asked for the other ' + oneOf(finding.parent) + '\'s rows in ' + finding.table +
716
- ' and got ' + finding.readable + ' back' + (holds ? ', including ' + holds : '') + '. With an ordinary role they got none. ' +
717
- 'So anybody who is ' + aRole(finding.asRole) + ' anywhere - which in most apps means anybody who creates their own ' +
718
- oneOf(finding.parent) + ' - can read every ' + oneOf(finding.parent) + '\'s rows here.'
719
- );
720
- }
721
- if (finding.tenant) {
722
- return (
723
- 'Signed in as someone in a different organization, I asked for another organization\'s rows and got ' +
724
- finding.readable + ' of them back' + (holds ? ', including ' + holds : '') +
725
- '. Anyone with an account can read every organization\'s rows.'
726
- );
727
- }
728
- return (
729
- 'Signed in as one customer, I asked for another customer\'s rows and got ' +
730
- finding.readable + ' of them back' + (holds ? ', including ' + holds : '') +
731
- '. Every customer you have can do this to every other customer.'
732
- );
733
- }
734
-
735
- /**
736
- * The thing they paste.
737
- *
738
- * Written to the tool they already use, which means it has to carry its own
739
- * context - the assistant on the other end has not seen any of this. It names
740
- * the table, says what is wrong in terms of the code rather than the symptom,
741
- * and asks for the same mistake to be swept up elsewhere, because it is almost
742
- * never in only one place.
743
- */
744
- /** Wraps one paragraph to a width that reads in a chat box and a terminal. */
745
- function wrapTo(text, width) {
746
- const words = String(text).split(/\s+/);
747
- const out = [];
748
- let current = '';
749
- for (const word of words) {
750
- if ((current + ' ' + word).trim().length > width) {
751
- out.push(current.trim());
752
- current = word;
753
- } else {
754
- current = (current + ' ' + word).trim();
755
- }
756
- }
757
- if (current) out.push(current.trim());
758
- return out;
759
- }
760
-
761
- /**
762
- * Whether the app signs people in through Supabase: any foreign key to
763
- * auth.users, or any rule that asks auth.uid() or auth.jwt(). Decided for the
764
- * whole app, not table by table. Found on a blind test: appointments points
765
- * at profiles, which points at auth.users - no key of its own and no rule
766
- * asking auth.uid(), so it was told the app "signs people in its own way"
767
- * when it plainly used Supabase's. Read from the original schema, not the
768
- * copy - the copy points those keys at a stand-in. A table missing from the
769
- * map is unknown, which the prompts treat as tied.
770
- */
771
- function authTiesOf(plan) {
772
- const tables = (plan && plan.tables) || [];
773
- const keyed = tables.some((table) => (table.constraints || []).some((c) =>
774
- c.kind === 'f' && /references\s+"?auth"?\s*\.\s*"?users"?\s*\(/i.test(String(c.definition || ''))));
775
- const asked = ((plan && plan.policies) || []).some((policy) =>
776
- /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(String(policy.qual || '') + ' ' + String(policy.with_check || '')));
777
- return new Map(tables.map((table) => [table.name, keyed || asked]));
778
- }
779
-
780
- /**
781
- * The fix for a table no rule can tie to the signed-in user, or null when one
782
- * can. Two ways that happens. The app has its own login: found on a real app,
783
- * "compare the owner to the id of the signed-in user" cannot be done there -
784
- * auth.uid() is empty for everybody - and an assistant that writes it anyway
785
- * locks the app out of its own table. Or the table has no column saying whose
786
- * row it is - a waitlist, a contact form - so there is nothing to compare.
787
- */
788
- // A column that says which organization a row belongs to. Such a table is
789
- // not "nobody's" - its rows are a team's.
790
- const TENANT_COLUMN = /^(org_id|organization_id|workspace_id|team_id|tenant_id|company_id)$/i;
791
-
792
- function serverOnlyLines(finding) {
793
- const table = finding.table;
794
- let why;
795
- // Found on a fix test: api_usage has org_id, and the prompt said it had no
796
- // column saying whose row it is. The assistant applying it had to correct
797
- // the prompt; a less careful one would have locked the table away from the
798
- // members who use it.
799
- // A row an organization owns names its organization even when the column is
800
- // not called organization_id: the attack found which key it is.
801
- const team = finding.authTied !== false &&
802
- (finding.tenant ? finding.owner : (finding.columns || []).find((c) => TENANT_COLUMN.test(String(c))));
803
- const maker = finding.owner || finding.ownedBy;
804
- // A team's row that also says who made it - created_by beside workspace_id.
805
- // Found on a benchmark: the prompt said to compare created_by to auth.uid()
806
- // for reading, which hides every teammate's rows and breaks the shared
807
- // view the table exists for. Who made a row decides who may write it
808
- // under their name, not who may see it.
809
- if (team && !finding.tenant && finding.owned !== false && maker) {
810
- return [
811
- 'Rows in "' + table + '" belong to a team ("' + team + '"), and "' + maker + '" only records who ' +
812
- 'made each one. So reading should be decided by team membership: let a signed-in user read a ' +
813
- 'row when they are a member of that row\'s "' + team + '" - checked through your membership ' +
814
- 'table, or a helper function that asks it - and give logged-out visitors nothing. Do not ' +
815
- 'compare "' + maker + '" to auth.uid() for reading: that hides teammates\' rows from each other ' +
816
- 'and breaks the feature.',
817
- '',
818
- 'Write separate rules for reading, inserting, updating and deleting. Inserting: WITH CHECK that ' +
819
- 'the user is a member of the row\'s "' + team + '" AND "' + maker + '" = auth.uid(), so nobody ' +
820
- 'writes a row under somebody else\'s name or into a team they are not in. Updating and ' +
821
- 'deleting: the membership check in USING (and WITH CHECK for updating), and decide whether any ' +
822
- 'member may do it or only the row\'s "' + maker + '" or an admin. If only your server writes ' +
823
- 'this table, add no write rules at all.',
824
- '',
825
- ];
826
- }
827
- const tenant = (finding.tenant || finding.owned === false) && team;
828
- if (tenant) {
829
- return [
830
- 'Rows in "' + table + '" belong to an organization ("' + tenant + '"), not to one person. So the ' +
831
- 'rule should let a signed-in user in only when they are a member of that row\'s organization - ' +
832
- 'checked through your membership table, or a helper function that asks it - and give logged-out ' +
833
- 'visitors nothing.',
834
- '',
835
- 'Write separate rules for reading, inserting, updating and deleting, each with that membership ' +
836
- 'check (USING for reading, updating and deleting, WITH CHECK for inserting and updating), and ' +
837
- 'decide whether every member should be able to write or only admins. If only your server ' +
838
- 'writes this table, add no write rules at all.',
839
- '',
840
- ];
841
- }
842
- if (finding.authTied === false) {
843
- why = 'Nothing in "' + table + '" points at auth.users and no rule on it uses auth.uid(), so your ' +
844
- 'app signs people in its own way. A rule comparing a column to the signed-in user cannot ' +
845
- 'work here - auth.uid() is empty for everyone - and adding one would lock your own app out.';
846
- } else if (finding.owned === false) {
847
- why = '"' + table + '" has no column that says whose row it is, so a rule tying rows to the ' +
848
- 'signed-in user has nothing to compare against.';
849
- } else {
850
- return null;
851
- }
852
- return [
853
- why,
854
- '',
855
- 'If only your server reads and writes this table: keep row level security on, drop the rules ' +
856
- 'that let everyone in, and add none for anon or authenticated. Have your server use the ' +
857
- 'service_role key, which skips these rules - and keep that key on the server only, never ' +
858
- 'in the browser or in the app\'s public code.',
859
- '',
860
- 'If the browser needs to read or write part of it directly, tell me which part first, so we ' +
861
- 'write the narrowest rule for exactly that.',
862
- '',
863
- ];
864
- }
865
-
866
- /**
867
- * The opening of a rules fix. "Switch row level security on" is said only
868
- * when it is off: told it about a table where it was already on, the person
869
- * looks for a switch that is already flipped and misses the rule that is the
870
- * real problem.
871
- */
872
- function switchOn(finding) {
873
- return finding.rlsEnabled
874
- ? 'Row level security is already on for this table, so the rules on it are the problem: '
875
- : 'Switch row level security on for this table, then ';
876
- }
877
-
878
- /**
879
- * One prompt for everything wrong with one table's rules - read and write,
880
- * strangers and customers - because it is one rule to replace, not four.
881
- */
882
- function groupPromptFor(members) {
883
- const lead = members.find((m) => m.kind === 'writable') || members[0];
884
- const cause = causeOf(lead);
885
- const table = lead.table;
886
- const owner = (members.find((m) => m.owner) || {}).owner;
887
- const ownerWords = owner ? '"' + owner + '"' : 'the column that says who each row belongs to';
888
- const read = members.find((m) => m.kind === 'exposed');
889
- const writers = members.filter((m) => m.kind === 'writable');
890
- const anyoneWrites = writers.find((w) => w.who === 'anyone');
891
- const customersWrite = writers.find((w) => w.who !== 'anyone');
892
- const what = [];
893
- if (read) what.push('read');
894
- if (writers.length) what.push('written');
895
- const who = read || anyoneWrites ? 'anyone who is not logged in' : 'any signed-in user, including other users\' rows';
896
- const did = [];
897
- if (read) did.push('read ' + read.readable + ' ' + (read.readable === 1 ? 'row' : 'rows'));
898
- const moves = [];
899
- for (const w of writers) for (const m of w.can || []) if (!moves.includes(m)) moves.push(m);
900
- for (const m of ['add', 'change', 'delete']) {
901
- if (moves.includes(m)) did.push({ add: 'added a row', change: 'changed rows', delete: 'deleted rows' }[m]);
902
- }
903
- const lines = [
904
- 'My app has a security problem.',
905
- '',
906
- 'The "' + table + '" table can be ' + what.join(' and ') + ' by ' + who +
907
- ', because ' + cause.short + '. I proved it on a copy: I ' + listOf(did) + '.' +
908
- (anyoneWrites && customersWrite ? ' Signed-in users can do the same.' : ''),
909
- '',
910
- ].concat(
911
- serverOnlyLines(lead) || [
912
- switchOn(lead) + 'remove the rule that lets everyone in, and write separate rules for reading, ' +
913
- 'inserting, updating and deleting. A rule written FOR ALL covers far more than reading.',
914
- '',
915
- 'Each of them should compare ' + ownerWords + ' to the id of the signed-in user (auth.uid()): ' +
916
- 'USING for reading, updating and deleting, WITH CHECK for inserting and updating, so nobody ' +
917
- 'reads, changes or writes a row under somebody else\'s name. Give logged-out visitors no rule ' +
918
- 'at all unless they truly need one.',
919
- '',
920
- ],
921
- ['Then check every other table for the same thing.'],
922
- );
923
- return lines
924
- .map((paragraph) => (paragraph ? wrapTo(paragraph, 72) : ['']))
925
- .reduce((all, part) => all.concat(part), [])
926
- .join('\n');
927
- }
928
-
929
- function fixPromptFor(finding) {
930
- const cause = causeOf(finding);
931
- const owner = finding.owner ? '"' + finding.owner + '"' : 'the column that says who each row belongs to';
932
-
933
- const lines = finding.kind === 'recursive'
934
- ? [
935
- 'My app has a broken database rule.',
936
- '',
937
- 'A row level security policy on the "' + finding.table + '" table reads "' + finding.table +
938
- '" itself' + (finding.deep ? ', through a helper function it calls,' : '') +
939
- ' so Postgres stops every request that touches it with ' + loopError(finding) + '. I saw it when using ' +
940
- listOf((finding.reads || []).map((t) => '"' + t + '"')) + '.',
941
- '',
942
- 'Show me the policies on "' + finding.table + '" and on every table whose policy reads "' +
943
- finding.table + '", and find the lookup that goes back into "' + finding.table + '".',
944
- '',
945
- 'Move that lookup into a SECURITY DEFINER function that answers only about the signed-in ' +
946
- 'user - for example is_member_of(org uuid) returning whether auth.uid() is in that ' +
947
- 'organization - with SET search_path fixed, owned by the table\'s owner so it reads the ' +
948
- 'table without the rule applying again. Grant EXECUTE on it to authenticated only. Then ' +
949
- 'make the policies call that function instead of reading "' + finding.table + '" directly.',
950
- '',
951
- 'Keep each policy exactly as strict as it was meant to be: members see only their own ' +
952
- 'organization\'s rows, and logged-out visitors get nothing. Do not replace it with USING ' +
953
- '(true) to make the error go away.',
954
- '',
955
- 'Then sign in as an ordinary user and open the pages that use these tables, and run this ' +
956
- 'check again - the tables it could not test will be tested this time.',
957
- ]
958
- : finding.kind === 'role' && finding.takeover
959
- ? [
960
- 'My app has a permissions problem - a member can take over another member\'s place.',
961
- '',
962
- 'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
963
- finding.roleColumn + '" is "' + finding.who + '" can update another member\'s row in "' + finding.via +
964
- '" and set "' + finding.personColumn + '" to their own id - taking that member\'s place and role, an ' +
965
- 'admin\'s or the owner\'s included. This was proved on a copy of the database.',
966
- '',
967
- 'Fix the rules on "' + finding.via + '" that cover UPDATE or ALL: nobody may change "' + finding.personColumn +
968
- '" or the team a membership belongs to on an existing row - adding or removing members is how membership ' +
969
- 'changes. Let only the roles that manage the team (usually owner and admin) update membership rows at ' +
970
- 'all, check that in WITH CHECK as well as USING, and never use WITH CHECK (true). If members only need to ' +
971
- 'change something of their own, allow exactly that column on their own row and nothing else.',
972
- '',
973
- 'Then check every function that updates "' + finding.via + '" for the same thing.',
974
- ]
975
- : finding.kind === 'role' && finding.movein
976
- ? [
977
- 'My app has a security problem - anyone signed in can make themselves ' + aRole(finding.toRole) + ' of any ' +
978
- oneOf(finding.parent) + '.',
979
- '',
980
- 'In my database, a signed-in user can update their own row in "' + finding.via + '" and change ' +
981
- listOf((finding.moved || []).map((c) => '"' + c + '"')) + ' to another ' + oneOf(finding.parent) +
982
- ' - one they were never invited to - and "' + finding.roleColumn + '" to "' + finding.toRole + '". The rule only ' +
983
- 'checks that the row is theirs, not what they change in it. This was proved on a copy of the database.',
984
- '',
985
- 'Fix the rules on "' + finding.via + '" that cover UPDATE or ALL: nobody may change ' +
986
- listOf((finding.moved || []).map((c) => '"' + c + '"')) + ' on an existing row, and a member must never be ' +
987
- 'able to change "' + finding.roleColumn + '" on their own row. Joining happens only by an invitation that ' +
988
- 'somebody who manages that ' + oneOf(finding.parent) + ' made. Let only the roles that manage the team change ' +
989
- 'membership rows, checked in WITH CHECK as well as USING - for example with a helper that reads "' + finding.via +
990
- '" for auth.uid() and the row\'s own ' + listOf((finding.moved || []).map((c) => '"' + c + '"')) + '.',
991
- '',
992
- 'Then check every function that updates "' + finding.via + '" for the same thing.',
993
- ]
994
- : finding.kind === 'role' && finding.promote
995
- ? [
996
- 'My app has a security problem - a member can make themselves ' + aRole(finding.toRole) + '.',
997
- '',
998
- 'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
999
- finding.roleColumn + '" is "' + finding.who + '" can update their own row in "' + finding.via + '" and set "' +
1000
- finding.roleColumn + '" to "' + finding.toRole + '". The rule only checks that the row is theirs, not what ' +
1001
- 'they change in it. This was proved on a copy of the database.',
1002
- '',
1003
- 'Fix the rules on "' + finding.via + '" that cover UPDATE or ALL: a member must never be able to change "' +
1004
- finding.roleColumn + '" on their own row. Let only the roles that manage the team (usually owner and admin) ' +
1005
- 'change roles, checked in WITH CHECK as well as USING - for example with a helper that reads "' + finding.via +
1006
- '" for auth.uid() - and make sure nobody can raise anyone above their own role. If members need to edit ' +
1007
- 'something else of their own, allow exactly those columns and not "' + finding.roleColumn + '".',
1008
- '',
1009
- 'Then check every function that updates "' + finding.via + '" for the same thing.',
1010
- ]
1011
- : finding.kind === 'role'
1012
- ? [
1013
- 'My app may have a permissions problem - please check it rather than assume it.',
1014
- '',
1015
- 'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
1016
- finding.roleColumn + '" is "' + finding.who + '" can ' +
1017
- listOf((finding.can || []).map((what) => ({ add: 'add', change: 'change', delete: 'delete' })[what])) +
1018
- ' rows of that ' + oneOf(finding.parent) + ' in the "' + finding.table + '" table. This was proved ' +
1019
- 'on a copy of the database: ' + aRole(finding.who) + ' was added, and the writes went through.',
1020
- '',
1021
- 'First tell me: should ' + aRole(finding.who) + ' be able to do that? If yes, change nothing.',
1022
- '',
1023
- 'If not, make the rules on "' + finding.table + '" that cover UPDATE, DELETE, INSERT or ALL check ' +
1024
- 'the member\'s role as well as membership - for example with a helper that reads "' + finding.via +
1025
- '" for auth.uid() and the roles allowed to edit. Postgres lets a write through if ANY permissive ' +
1026
- 'policy allows it, so a strict rule beside a loose one does nothing: list every such policy on "' +
1027
- finding.table + '" and narrow or drop the loose one. Keep the read rule as it is if ' +
1028
- aRole(finding.who) + ' should still see these rows.',
1029
- '',
1030
- 'Then check every other table where members can write for the same thing.',
1031
- ]
1032
- : finding.kind === 'teamread'
1033
- ? [
1034
- 'My app may have a permissions problem - please check it rather than assume it.',
1035
- '',
1036
- 'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
1037
- finding.roleColumn + '" is "' + finding.who + '" can read the "' + finding.table + '" table of that ' +
1038
- oneOf(finding.parent) + ', including ' + listOf((finding.secrets || []).map((c) => '"' + c + '"')) +
1039
- '. This was proved on a copy of the database: ' + aRole(finding.who) + ' was added, and the rows came back.',
1040
- '',
1041
- 'First tell me: should ' + aRole(finding.who) + ' see these? If yes, change nothing.',
1042
- '',
1043
- 'If not, make the rules on "' + finding.table + '" that cover SELECT or ALL check the member\'s role as ' +
1044
- 'well as membership. Postgres lets a read through if ANY permissive policy allows it, so a strict rule ' +
1045
- 'beside a loose one does nothing: list every such policy on "' + finding.table + '" and narrow or drop ' +
1046
- 'the loose one. If members need the other columns, a view or function that leaves the secret ones out ' +
1047
- 'is often the better fix.',
1048
- '',
1049
- 'Then check every other table that holds tokens, keys, secrets or payloads for the same thing.',
1050
- ]
1051
- : finding.kind === 'bucket'
1052
- ? [
1053
- 'My app may have a storage problem - please check it rather than assume it.',
1054
- '',
1055
- 'The Supabase Storage bucket "' + finding.bucket + '" is public. Anyone with a file\'s URL can ' +
1056
- 'download it without logging in' + (finding.noRule && (finding.writers || []).length
1057
- ? ', and no rule on storage.objects says who may read it - though ' +
1058
- listOf((finding.writers || []).map((r) => '"' + r + '"')) + ' let only signed-in members upload or delete ' +
1059
- 'its files, which suggests they are not meant for everyone.'
1060
- : finding.noRule
1061
- ? ', and no rule on storage.objects says who may read it - its name suggests its files are not ' +
1062
- 'meant for everyone.'
1063
- : ', and the storage.objects ' +
1064
- ((finding.rules || []).length === 1 ? 'policy that names' : 'policies that name') + ' this bucket (' +
1065
- listOf((finding.rules || []).map((r) => '"' + r + '"')) + ') ' +
1066
- ((finding.rules || []).length === 1 ? 'is' : 'are') + ' not checked for public URLs - so ' +
1067
- ((finding.rules || []).length === 1 ? 'it protects' : 'they protect') + ' nothing while it is public.'),
1068
- '',
1069
- 'First tell me: is every file in this bucket meant to be downloadable by anyone? If yes, change nothing.',
1070
- '',
1071
- 'If not, make the bucket private (in the Supabase dashboard: Storage, edit the bucket, turn Public ' +
1072
- 'off - or UPDATE storage.buckets SET public = false WHERE id = \'' + finding.bucket + '\'). Then ' +
1073
- 'change the app to fetch these files with signed URLs (createSignedUrl) instead of getPublicUrl, ' +
1074
- 'or the links it shows will stop working.',
1075
- '',
1076
- finding.noRule
1077
- ? 'Once it is private, add a read rule on storage.objects for this bucket that lets in only the ' +
1078
- 'people a file belongs to - without one, nobody but your server can read them.'
1079
- : 'Keep the read rule: once the bucket is private it is what decides who gets a file. Check that it ' +
1080
- 'only lets in the right people.',
1081
- '',
1082
- 'Then look at every other bucket for the same thing.',
1083
- ]
1084
- : finding.kind === 'searchpath'
1085
- ? [
1086
- 'My app may have a security weakness - please check it rather than assume it.',
1087
- '',
1088
- 'The database function "' + finding.fn + '(' + (finding.args || '') + ')" is SECURITY DEFINER ' +
1089
- '(it runs with its owner\'s rights) and does not set a search_path, so names inside it ' +
1090
- 'without a schema are looked up along the caller\'s path.',
1091
- '',
1092
- 'Fix only this: add SET search_path = \'\' to the function\'s definition, and write every ' +
1093
- 'table, view and function it uses with its schema - public.scans, not scans; auth.uid() ' +
1094
- 'stays as it is. Keep its body, its checks and its grants exactly as they are.',
1095
- '',
1096
- 'Then call it once the way the app does, to make sure it still works, and look at every ' +
1097
- 'other SECURITY DEFINER function for the same thing.',
1098
- ]
1099
- : finding.kind === 'conflict'
1100
- ? [
1101
- 'My app has a broken database function.',
1102
- '',
1103
- 'The function "' + finding.fn + '(' + (finding.args || '') + ')" runs INSERT INTO ' +
1104
- finding.target + ' ... ON CONFLICT (' + finding.on + '), and Postgres refuses it with ' +
1105
- '"there is no unique or exclusion constraint matching the ON CONFLICT specification" - so ' +
1106
- 'it fails on every call.',
1107
- '',
1108
- finding.partialWhere
1109
- ? 'The unique index on (' + finding.on + ') is partial: WHERE ' + finding.partialWhere + '. ' +
1110
- 'Repeat that condition in the function right after the conflict columns: ON CONFLICT (' +
1111
- finding.on + ') WHERE ' + finding.partialWhere + ' DO ... - and keep the rest of the ' +
1112
- 'statement as it is. Do not drop the index\'s WHERE: it is what lets rows without a value exist.'
1113
- : 'Make the ON CONFLICT match a unique rule that exists: either add a unique constraint on ' +
1114
- finding.target + ' (' + finding.on + ') if those columns really must be unique together, or ' +
1115
- 'change the conflict columns to an existing unique key. Tell me which you chose and why.',
1116
- '',
1117
- 'Then call the function twice with the same values: the second call must update the row, ' +
1118
- 'not error and not add a second one.',
1119
- ]
1120
- : finding.kind === 'nullcheck'
1121
- ? [
1122
- 'My app may have a data problem - please check it rather than assume it.',
1123
- '',
1124
- 'In the "' + finding.table + '" table, the column "' + finding.column + '" has a CHECK that ' +
1125
- 'allows only ' + listOf((finding.allowed || []).map((v) => '\'' + v + '\'')) + ', but the column ' +
1126
- 'can be empty (NULL), and a CHECK lets NULL through.',
1127
- '',
1128
- 'First tell me: should every row have a ' + finding.column + '? If an empty one is meant to be ' +
1129
- 'allowed, change nothing and say so.',
1130
- '',
1131
- 'If every row should have one: set the empty ones first (UPDATE "' + finding.table + '" SET "' +
1132
- finding.column + '" = <the right value> WHERE "' + finding.column + '" IS NULL - ask me what ' +
1133
- 'the right value is), then ALTER TABLE "' + finding.table + '" ALTER COLUMN "' + finding.column +
1134
- '" SET NOT NULL. Change nothing else.',
1135
- ]
1136
- : finding.kind === 'noinsert'
1137
- ? [
1138
- 'My app may have a bug - please check it rather than assume it.',
1139
- '',
1140
- 'In my database, the rule' + ((finding.rules || []).length === 1 ? ' ' : 's ') +
1141
- listOf((finding.rules || []).map((r) => '"' + r + '"')) + ' on "' + finding.table + '" for INSERT ' +
1142
- ((finding.rules || []).length === 1 ? 'has' : 'have') + ' no WITH CHECK, and there is no other rule that ' +
1143
- 'allows adding rows. Postgres treats a missing check as false, so nobody can add a row to "' + finding.table +
1144
- '" - not even through a function that runs as the user.',
1145
- '',
1146
- 'First tell me: is adding rows to "' + finding.table + '" meant to work for signed-in users? If only your ' +
1147
- 'server adds them, change nothing.',
1148
- '',
1149
- 'If users should be able to add them: give the rule a WITH CHECK that says who may - for example that ' +
1150
- 'the user is a member of the row\'s organization (through your membership table or a helper) and that ' +
1151
- 'the column naming who created it equals auth.uid(). Never use WITH CHECK (true): that lets anyone add ' +
1152
- 'rows anywhere. Then add one row through the app to make sure it works.',
1153
- ]
1154
- : finding.kind === 'privileged'
1155
- ? [
1156
- 'My app may have a security problem - please check it rather than assume it.',
1157
- '',
1158
- 'The database function "' + finding.fn + '(' + (finding.args || '') + ')" is SECURITY ' +
1159
- 'DEFINER, so it runs with its owner\'s rights and ignores row level security. It is ' +
1160
- 'granted EXECUTE to "anon", so anyone who is not logged in can call it' +
1161
- (finding.writes ? ', and it writes to tables.' : '.'),
1162
- '',
1163
- 'First tell me: is it meant to be callable by logged-out visitors? If it is not, ' +
1164
- 'REVOKE EXECUTE ON FUNCTION "' + finding.fn + '"(' + (finding.args || '') + ') FROM anon, PUBLIC.',
1165
- '',
1166
- // Found on a blind test (Northstar): get_recent_findings(org id) was
1167
- // the app's own RPC for signed-in members, and "grant it to
1168
- // service_role" would have broken the page that calls it.
1169
- 'Then decide who should run it. If signed-in users of the app call it, keep it granted to ' +
1170
- 'authenticated and add a check at its top that the caller - auth.uid() - may act on what ' +
1171
- 'they passed in, for example that they are a member (or an admin) of the organization ' +
1172
- 'whose id it takes, and RAISE an exception otherwise. Only if nothing but your own server ' +
1173
- 'calls it, grant it to service_role alone.',
1174
- '',
1175
- ].concat(
1176
- finding.nullSlips
1177
- ? [
1178
- 'Its role check lets through anyone with no role at all: the helper returns NULL for them, and ' +
1179
- 'NULL NOT IN (...) - or NULL <> ... - is not true, so the IF never raises. Write the check so ' +
1180
- 'NULL cannot pass, for example IF coalesce(<the helper>(...), \'none\') NOT IN (...) THEN RAISE, ' +
1181
- 'or IF <the helper>(...) IS NULL OR <the helper>(...) NOT IN (...) THEN RAISE - and check every ' +
1182
- 'other function that uses the same helper the same way.',
1183
- '',
1184
- ]
1185
- : [],
1186
- finding.hasFixedSearchPath
1187
- ? []
1188
- : [
1189
- 'It also does not set a fixed search_path. Add SET search_path = pg_catalog, public (or ' +
1190
- 'the schemas it truly needs) so a caller cannot make it resolve names to their own objects.',
1191
- '',
1192
- ],
1193
- [
1194
- 'If it is meant to be public, keep it, but make sure everything inside it checks what the ' +
1195
- 'caller is allowed to see or change itself - the table rules will not do it here.',
1196
- '',
1197
- 'Then look at every other SECURITY DEFINER function for the same grant.',
1198
- ],
1199
- )
1200
- : finding.kind === 'orphaned'
1201
- ? [
1202
- 'My app has a data problem.',
1203
- '',
1204
- 'The "' + finding.table + '" table has a "' + finding.column +
1205
- '" column that is meant to point at "' + finding.parent + '".' +
1206
- ' There is no foreign key between them, so the database does not know they ' +
1207
- 'are related. I proved it by adding a row naming one that does not exist, ' +
1208
- 'and it was accepted.',
1209
- '',
1210
- 'Add a foreign key from "' + finding.table + '"."' + finding.column +
1211
- '" to "' + finding.parent + '"."' + finding.parentKey + '".',
1212
- '',
1213
- 'Decide on purpose what should happen when the parent row is deleted: ON ' +
1214
- 'DELETE CASCADE if the children should go too, ON DELETE SET NULL if they ' +
1215
- 'should stay without an owner, or nothing at all if the delete should be ' +
1216
- 'refused while children exist.',
1217
- '',
1218
- 'There may already be rows pointing at nothing, and the constraint will not ' +
1219
- 'be created until those are dealt with. Find them first.',
1220
- ]
1221
- : finding.kind === 'writable'
1222
- ? [
1223
- 'My app has a security problem.',
1224
- '',
1225
- 'The "' + finding.table + '" table can be written to by ' +
1226
- (finding.who === 'anyone' ? 'anyone who is not logged in' : 'any signed-in user, including other users rows') +
1227
- ', because ' + cause.short + '. I proved it: I ' +
1228
- (finding.can || []).map((what) => ({ add: 'added a row', change: 'changed rows', delete: 'deleted rows' })[what])
1229
- .join(', ') + '.',
1230
- '',
1231
- ].concat(
1232
- serverOnlyLines(finding) || [
1233
- switchOn(finding) + 'write separate rules for reading, inserting, updating and deleting. ' +
1234
- 'A rule written FOR SELECT does not cover writes, and a rule written FOR ALL covers ' +
1235
- 'far more than reading.',
1236
- '',
1237
- 'For insert and update, use WITH CHECK comparing ' + owner +
1238
- ' to the id of the signed-in user, so nobody can write a row under somebody ' +
1239
- "else's name. For update and delete, add USING with the same comparison: " +
1240
- 'with USING (true) left on the update, anyone can take a row that is not ' +
1241
- 'theirs and rewrite it as their own.',
1242
- '',
1243
- ],
1244
- [
1245
- 'Then check every other table for the same thing - a table with no row level ' +
1246
- 'security on it is writable by default.',
1247
- ],
1248
- )
1249
- : finding.kind === 'duplicated'
1250
- ? [
1251
- 'My app has a security problem.',
1252
- '',
1253
- 'Two rows in the "' + finding.table + '" table can hold the same "' + finding.column +
1254
- '". I proved it by inserting the same value from two connections at the same ' +
1255
- 'moment, and both were accepted.',
1256
- '',
1257
- // First, because it has to happen before anything is changed. Learned
1258
- // by applying the short version of this prompt literally to a real app:
1259
- // the constraint cannot be created while duplicates exist, and an
1260
- // assistant told only "add a constraint" may delete rows to get there.
1261
- 'First, find the rows that already share a value and show them to me. Do not ' +
1262
- 'delete or merge any of them without asking me: the rule below cannot be ' +
1263
- 'created while they exist, and deleting rows to make it pass loses real data.',
1264
- '',
1265
- (finding.expectation === 'identity'
1266
- ? 'Then add a unique index on lower("' + finding.column + '"), so that "' + finding.table +
1267
- '"."' + finding.column + '" is unique whatever the case - Ann@example.com and ' +
1268
- 'ann@example.com are the same person.'
1269
- : 'Then add a unique constraint on "' + finding.table + '"."' + finding.column + '".') +
1270
- ' It has to be in the database itself. Checking in application code before inserting ' +
1271
- 'is not enough - between the check and the insert, the other request has already gone in.',
1272
- '',
1273
- // Said because the obvious fix is wrong for multi-tenant apps, and being
1274
- // told to drop a legitimate design would cost them more than the bug.
1275
- 'If the same value is allowed to repeat for different owners, make it cover both ' +
1276
- 'columns together rather than leaving it off.',
1277
- '',
1278
- ].concat(
1279
- [
1280
- 'Find everything that writes this column - app code, and any database trigger or ' +
1281
- 'function, such as one that copies each new sign-up into this table - and make ' +
1282
- 'sure each one handles "this value already exists" instead of failing. If people ' +
1283
- 'can edit this column themselves, decide whether they should: once it is unique, ' +
1284
- 'someone who puts another person\'s value in first will block that person.',
1285
- '',
1286
- ],
1287
- finding.expectation === 'identity' && finding.loginElsewhere
1288
- ? [
1289
- 'Sign-in uses auth.users, so this column is only a copy of the email there. ' +
1290
- 'Consider not letting people edit it at all and keeping it in step with auth.users.',
1291
- '',
1292
- ]
1293
- : [],
1294
- [
1295
- 'Afterwards, sign up a brand-new account and edit an existing profile to make sure ' +
1296
- 'both still work, and run this check again.',
1297
- '',
1298
- 'Then look for the same missing constraint on every other table and fix those too.',
1299
- ],
1300
- )
1301
- : finding.asRole
1302
- ? [
1303
- 'My app has a security problem.',
1304
- '',
1305
- 'In "' + finding.table + '", anyone who is "' + finding.asRole + '" in their own ' + oneOf(finding.parent) + ' can read the rows ' +
1306
- 'of every ' + oneOf(finding.parent) + '. I proved it on a copy: as "' + finding.asRole + '" of one ' + oneOf(finding.parent) +
1307
- ', I read ' + finding.readable + ' row(s) of another.',
1308
- '',
1309
- 'A rule on "' + finding.table + '" checks the caller\'s role without tying it to the row - for example ' +
1310
- 'exists (select 1 from ' + finding.via + ' m where m.' + finding.roleColumn + ' = \'' + finding.asRole + '\') with no ' +
1311
- 'condition that m is the signed-in user\'s membership of THIS row\'s ' + oneOf(finding.parent) + '. Fix every rule on "' +
1312
- finding.table + '": a role check must say m.user_id = auth.uid() AND m.<' + oneOf(finding.parent) + ' id> = the row\'s ' +
1313
- oneOf(finding.parent) + ' id - or call a helper that does. Keep membership as the way in; do not replace it with USING (true).',
1314
- '',
1315
- 'Then check every other rule and helper that checks a role for the same missing link.',
1316
- ]
1317
- : finding.isView
1318
- ? [
1319
- 'My app has a security problem.',
1320
- '',
1321
- 'The "' + finding.table + '" view can be read by anyone who is not logged in. ' +
1322
- 'A view runs with the rights of whoever created it, so the row level security ' +
1323
- 'on the tables underneath is never checked against the person asking.',
1324
- '',
1325
- 'Fix it by recreating the view with security_invoker set on, so it runs as the ' +
1326
- 'visitor and the rules on the underlying tables apply - and make sure those ' +
1327
- 'tables actually have those rules.',
1328
- '',
1329
- 'Then check every other view in the app for the same thing.',
1330
- ]
1331
- : [
1332
- 'My app has a security problem.',
1333
- '',
1334
- 'The "' + finding.table + '" table ' +
1335
- (finding.kind === 'exposed'
1336
- ? 'can be read by anyone who is not logged in, because ' + cause.short + '.'
1337
- : finding.tenant
1338
- ? 'lets a signed-in user read rows belonging to a different organization, because ' + cause.short + '.'
1339
- : 'lets one signed-in user read rows belonging to a different user, because ' + cause.short + '.'),
1340
- '',
1341
- ].concat(
1342
- serverOnlyLines(finding) || [
1343
- 'Fix it so a person can only read their own rows: compare ' + owner +
1344
- ' against the id of the signed-in user, and make sure logged-out visitors get nothing.',
1345
- '',
1346
- ],
1347
- ['Then look for the same mistake on every other table and fix those too.'],
1348
- );
1349
- // Wrapped here rather than by whoever prints it: this text is pasted into a
1350
- // chat box as often as it is read in a terminal, and an unwrapped paragraph
1351
- // is a wall in both.
1352
- return lines
1353
- .map((paragraph) => (paragraph ? wrapTo(paragraph, 72) : ['']))
1354
- .reduce((all, part) => all.concat(part), [])
1355
- .join('\n');
1356
- }
1357
-
1358
- // Found and said, but never claimed as a break: the owner decides. Counted
1359
- // apart in the report, never towards the exit code or a clean re-check.
1360
- const TO_CHECK = ['privileged', 'role', 'teamread', 'bucket', 'searchpath', 'nullcheck', 'noinsert'];
1361
- // Functions, said by their name rather than a table's.
1362
- const ON_A_FUNCTION = ['privileged', 'searchpath', 'conflict'];
1363
-
1364
- /** Everything the report needs about one thing the attack found. */
1365
- function describe(finding) {
1366
- const contents = readContents(finding.columns);
1367
- const cause = causeOf(finding);
1368
- return {
1369
- severity: severityOf(finding, contents),
1370
- // Every other finding here was proven by an attack that ran; this is the
1371
- // one the tool reasons about without executing, so it says so. The report
1372
- // and the re-check both read this: a "verification required" finding is
1373
- // never counted towards a clean re-check on its own.
1374
- status: TO_CHECK.includes(finding.kind) && !(finding.kind === 'role' && finding.promote) ? 'verification required' : 'confirmed',
1375
- table: ON_A_FUNCTION.includes(finding.kind) ? finding.fn : finding.table,
1376
- fn: finding.fn,
1377
- args: finding.args,
1378
- writes: finding.writes,
1379
- kind: finding.kind,
1380
- // Carried through because a table can have two different columns that each
1381
- // accept a duplicate, and the re-check tells one finding from another by
1382
- // what it is about. Without the column they would share an identity and
1383
- // fixing one would look like fixing both.
1384
- column: finding.column,
1385
- parent: finding.parent,
1386
- parentKey: finding.parentKey,
1387
- expectation: finding.expectation,
1388
- headline: headlineFor(finding, contents),
1389
- body: bodyFor(finding, contents),
1390
- // For a privileged function there is no "why the door is open" the way a
1391
- // table has one; the body already carries the whole explanation, so the
1392
- // cause line would only repeat it. Left empty and skipped by the report.
1393
- cause: TO_CHECK.includes(finding.kind) || finding.kind === 'conflict' ? '' : cause.long,
1394
- who: finding.who,
1395
- can: finding.can,
1396
- via: finding.via,
1397
- roleColumn: finding.roleColumn,
1398
- readable: finding.readable,
1399
- secrets: finding.secrets,
1400
- bucket: finding.bucket,
1401
- rules: finding.rules,
1402
- // An organization's rows read from outside it, and a member taking over
1403
- // another member's row: kept so the re-check and the read-back word them
1404
- // the same way the report did.
1405
- tenant: finding.tenant,
1406
- asRole: finding.asRole,
1407
- takeover: finding.takeover,
1408
- personColumn: finding.personColumn,
1409
- promote: finding.promote,
1410
- movein: finding.movein,
1411
- moved: finding.moved,
1412
- toRole: finding.toRole,
1413
- nullSlips: finding.nullSlips,
1414
- noRule: finding.noRule,
1415
- writers: finding.writers,
1416
- // Read from how a function or column is declared (scan.js searchPathsOf,
1417
- // conflictsIn, emptyChoicesOf); kept so the read-back says it the same way.
1418
- target: finding.target,
1419
- on: finding.on,
1420
- partialWhere: finding.partialWhere,
1421
- allowed: finding.allowed,
1422
- anon: finding.anon,
1423
- proof: finding.kind === 'searchpath'
1424
- ? 'I did not call "' + finding.fn + '". I read that it is SECURITY DEFINER with no search_path set.'
1425
- : finding.kind === 'conflict'
1426
- ? 'I asked Postgres to plan its INSERT INTO ' + finding.target + ' ... ON CONFLICT (' + finding.on +
1427
- ') on the copy, and it refused. I did not call the function.'
1428
- : finding.kind === 'nullcheck'
1429
- ? 'I read that "' + finding.table + '.' + finding.column + '" may be NULL and has no default. I did not insert anything.'
1430
- : finding.kind === 'noinsert'
1431
- ? 'I read that every rule for adding rows to "' + finding.table + '" (' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
1432
- ') has neither USING nor WITH CHECK. I did not insert anything.'
1433
- : finding.kind === 'recursive'
1434
- ? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
1435
- loopError(finding) + '.'
1436
- : finding.kind === 'privileged'
1437
- ? 'I did not call "' + finding.fn + '". This is a reach that exists in the grants, not a break I ran.'
1438
- : finding.kind === 'role' && finding.takeover
1439
- ? 'As ' + aRole(finding.who) + ' who had just joined, I put my own id on the other person\'s row of "' +
1440
- finding.via + '", which I could not touch before joining. It was undone.'
1441
- : finding.kind === 'role' && finding.movein
1442
- ? 'Signed in, and not a member, I moved my own row of "' + finding.via + '" into the other ' + oneOf(finding.parent) +
1443
- ' with "' + finding.roleColumn + '" set to "' + finding.toRole + '". It was undone.'
1444
- : finding.kind === 'role' && finding.promote
1445
- ? 'As ' + aRole(finding.who) + ' who had just joined, I set "' + finding.roleColumn + '" on my own row of "' +
1446
- finding.via + '" to "' + finding.toRole + '". It was undone.'
1447
- : finding.kind === 'crossed' && finding.asRole
1448
- ? 'As "' + finding.asRole + '" of my own ' + oneOf(finding.parent) + ', I read ' + finding.readable + ' row(s) of "' +
1449
- finding.table + '" belonging to another; with an ordinary role, none. My role was put back.'
1450
- : finding.kind === 'role'
1451
- ? 'As ' + aRole(finding.who) + ' who had just joined, I ' +
1452
- listOf((finding.can || []).map((what) => {
1453
- const n = (finding.changed || {})[what] || 1;
1454
- return ({ add: 'added ', change: 'changed ', delete: 'deleted ' })[what] + n + (n === 1 ? ' row' : ' rows');
1455
- })) + ' in "' + finding.table + '" that I could not touch before joining. All of it was undone.'
1456
- : finding.kind === 'teamread'
1457
- ? 'As ' + aRole(finding.who) + ' who had just joined, I read ' + (finding.readable || 1) +
1458
- ((finding.readable || 1) === 1 ? ' row' : ' rows') + ' of "' + finding.table + '" that I could not read before joining.'
1459
- : finding.kind === 'bucket' && finding.noRule
1460
- ? 'I read that "' + finding.bucket + '" is public and that no rule says who may read it' +
1461
- ((finding.writers || []).length ? ', while ' + listOf((finding.writers || []).map((r) => '"' + r + '"')) +
1462
- ' limit uploads and deletes to signed-in members' : '') + '. I did not open any file.'
1463
- : finding.kind === 'bucket'
1464
- ? 'I read that "' + finding.bucket + '" is public and that ' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
1465
- ' says who may read it. I did not open any file.'
1466
- : finding.kind === 'duplicated'
1467
- ? 'I created ' + finding.copies + ' rows in "' + finding.table + '" holding the same ' +
1468
- finding.column + '.'
1469
- : finding.countOnly
1470
- ? 'I counted ' + finding.readable + ' ' + (finding.readable === 1 ? 'row' : 'rows') +
1471
- ' in "' + finding.table + '" that a stranger can read, without pulling any of them out.'
1472
- : 'I read ' + finding.readable + ' ' + (finding.readable === 1 ? 'row' : 'rows') +
1473
- ' from "' + finding.table + '" that should not have been readable.',
1474
- fixPrompt: fixPromptFor(finding),
1475
- contents: contents,
1476
- };
1477
- }
1478
-
1479
- /** Everything wrong with one table's rules, said once. */
1480
- function mergeTable(group, one) {
1481
- const members = group.map(one);
1482
- const read = group.find((m) => m.kind === 'exposed');
1483
- const anyoneWrites = group.find((m) => m.kind === 'writable' && m.who === 'anyone');
1484
- const customersWrite = group.find((m) => m.kind === 'writable' && m.who !== 'anyone');
1485
- const lead = anyoneWrites || customersWrite || read;
1486
- const leadItem = members[group.indexOf(lead)];
1487
- const table = lead.table;
1488
- const order = ['delete', 'change', 'add'];
1489
- const does = (w) => order.filter((m) => (w.can || []).includes(m)).map((m) => WRITE_WORDS[m]);
1490
- let headline;
1491
- if (read && anyoneWrites) {
1492
- const verbs = ['read'].concat(order.filter((m) => (anyoneWrites.can || []).includes(m)));
1493
- headline = 'Anyone can ' + listOf(verbs) + ' rows in your ' + table + ' table.';
1494
- } else if (read && customersWrite) {
1495
- headline = 'Anyone can read your ' + table + ' table, and any signed-in customer can ' + listOf(does(customersWrite)) + ' it.';
1496
- } else {
1497
- headline = leadItem.headline;
1498
- }
1499
- // The customers' part is said in one line when strangers can already do it.
1500
- const bodies = [];
1501
- for (let i = 0; i < group.length; i++) {
1502
- if (group[i] === customersWrite && anyoneWrites) continue;
1503
- bodies.push(members[i].body);
1504
- }
1505
- if (customersWrite && anyoneWrites) bodies.push('Signed-in customers can do the same.');
1506
- const severity = members.some((m) => m.severity === 'CRITICAL') ? 'CRITICAL' : leadItem.severity;
1507
- const can = [];
1508
- for (const m of group) for (const c of m.can || []) if (!can.includes(c)) can.push(c);
1509
- return Object.assign({}, leadItem, {
1510
- severity: severity,
1511
- status: 'confirmed',
1512
- kind: lead.kind,
1513
- who: read || anyoneWrites ? 'anyone' : leadItem.who,
1514
- can: can,
1515
- headline: headline,
1516
- body: bodies.join(' '),
1517
- proof: members.map((m) => m.proof).join(' '),
1518
- fixPrompt: groupPromptFor(group),
1519
- members: members,
1520
- });
1521
- }
1522
-
1523
- /** The whole report, in the order a person should read it. */
1524
- function describeAll(findings) {
1525
- // A table anyone can read is also, necessarily, a table one customer can read
1526
- // of another. Reporting both puts the same table on screen twice and turns
1527
- // two problems into a list of three that reads like a mistake. The public one
1528
- // is kept, and it carries the rest.
1529
- const raw = findings || [];
1530
- const publiclyOpen = new Set(raw.filter((f) => f.kind === 'exposed').map((f) => f.table));
1531
- const kept = raw.filter((f) => !(f.kind === 'crossed' && publiclyOpen.has(f.table)));
1532
- const one = (f) => {
1533
- const item = Object.assign(describe(f), {
1534
- alsoCrossed: f.kind === 'exposed' && raw.some((o) => o.kind === 'crossed' && o.table === f.table),
1535
- });
1536
- if (item.alsoCrossed) item.body += ' Your signed-in customers can read each other\'s rows for the same reason.';
1537
- return item;
1538
- };
1539
-
1540
- // One table, one finding. A single rule written FOR ALL USING (true) opens a
1541
- // table to reading and writing, for strangers and customers alike - four
1542
- // proofs of one mistake. Listed as four, a real app's report showed six
1543
- // CRITICAL findings for two rules, and the person reads six problems where
1544
- // there are two to fix. The proofs are kept as members, so a re-check can
1545
- // still tell "strangers can no longer write" from "strangers can still read".
1546
- const grouped = new Map();
1547
- for (const f of kept) {
1548
- if ((f.kind !== 'exposed' && f.kind !== 'writable') || f.isView || f.countOnly) continue;
1549
- if (!grouped.has(f.table)) grouped.set(f.table, []);
1550
- grouped.get(f.table).push(f);
1551
- }
1552
- const described = [];
1553
- const done = new Set();
1554
- for (const f of kept) {
1555
- const group = grouped.get(f.table);
1556
- if (!group || group.length < 2 || group.indexOf(f) === -1) {
1557
- described.push(one(f));
1558
- continue;
1559
- }
1560
- if (done.has(f.table)) continue;
1561
- done.add(f.table);
1562
- described.push(mergeTable(group, one));
1563
- }
1564
- // Worst first, and within that the ones open to the whole internet before the
1565
- // ones that need an account, and those before the ones that need two requests
1566
- // to arrive together.
1567
- // Writes above reads: a table somebody emptied is worse than one they read.
1568
- // A looping rule sits with the reads: it breaks the app for everyone it
1569
- // fails for, which matters more than a duplicate or a stray row.
1570
- const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, conflict: 2.6, duplicated: 3, orphaned: 4, role: 4.5, teamread: 4.6, bucket: 4.7, privileged: 5, searchpath: 5.5, noinsert: 5.7, nullcheck: 6 };
1571
- const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
1572
- return described.sort((a, b) => rank(a) - rank(b));
1573
- }
1574
-
1575
- /** What is printed when a run finds nothing. Silence would read as a failure. */
1576
- function allClearLines(attacksRun) {
1577
- // Nothing attacked is not the same as nothing got through. The callers all
1578
- // stop before this now, but the sentence is the one thing in the product
1579
- // that must never be printed by accident, so it guards itself too.
1580
- if (!attacksRun) {
1581
- return ['', ' I did not manage to attack anything, so there is nothing to report.', ''];
1582
- }
1583
- // "Your data held" used to be the last line here, and it was the wrong
1584
- // sentence: it says something about the app, and all this program knows is
1585
- // something about the attacks it happened to run. A person who reads "your
1586
- // data held" stops looking. A person who reads "these attacks lost" knows
1587
- // what they have been handed and what they have not.
1588
- return [
1589
- '',
1590
- ' Nothing got through.',
1591
- '',
1592
- ' I ran ' + attacksRun + ' ' + (attacksRun === 1 ? 'attack' : 'attacks') + ' against a copy of your app,',
1593
- ' and every one of them lost.',
1594
- '',
1595
- ' That is not the same as "your app is safe". It means these attacks,',
1596
- ' against this shape of database, this time, did not get in. Anything I',
1597
- ' did not try is listed at the end.',
1598
- '',
1599
- ];
1600
- }
1601
-
1602
- module.exports = {
1603
- authTiesOf: authTiesOf,
1604
- readContents: readContents,
1605
- severityOf: severityOf,
1606
- causeOf: causeOf,
1607
- describe: describe,
1608
- describeAll: describeAll,
1609
- fixPromptFor: fixPromptFor,
1610
- allClearLines: allClearLines,
1611
- listOf: listOf,
1612
- };
1
+ // Turning what the attack saw into something a person can act on.
2
+ //
3
+ // The detection is the easy tenth. A builder who cannot read code does not buy
4
+ // "crossed:customers:1" - they buy knowing their customer list is open, seeing
5
+ // it happen, and having one thing to paste that closes it.
6
+ //
7
+ // The rule everything here follows: never say more than was actually seen. If
8
+ // the attack read a table with an email column in it, say email. If it did not,
9
+ // do not reach for "personal details" because it sounds more urgent. A report
10
+ // that overstates once is a report nobody trusts again, and trust is the entire
11
+ // product - the re-check at the end is worth exactly as much as the first
12
+ // report was honest.
13
+
14
+ /* --------------------------------------------------------------------------
15
+ What was in the table.
16
+ -------------------------------------------------------------------------- */
17
+
18
+ // Things that identify a person. Matched on whole words so that `company_name`
19
+ // counts and `renamed_at` does not.
20
+ const IDENTITY = [
21
+ [/\b(email|e_mail|mail)\b/i, 'email addresses'],
22
+ [/\b(phone|mobile|contact_number|telephone)\b/i, 'phone numbers'],
23
+ // `\b` treats "_" as part of a word, so display_name never matched `name` -
24
+ // found on a blind test, where a public profiles table was reported as
25
+ // holding nothing in particular. The compound names are listed instead.
26
+ [/\b(full_name|first_name|last_name|given_name|surname|name|display_name|username|user_name|nickname|screen_name)\b/i, 'names'],
27
+ [/\b(address|street|city|postcode|zip|pincode)\b/i, 'addresses'],
28
+ [/\b(dob|date_of_birth|birth_date|birthday)\b/i, 'dates of birth'],
29
+ ];
30
+
31
+ // Things that are worse than identifying - they let somebody act as the person,
32
+ // or take money.
33
+ const SECRETS = [
34
+ // `\b` counts "_" as a letter, so these missed every compound name - found
35
+ // on a blind test: integration_token, access_token and signing_secret, all
36
+ // readable by anyone, and the report named none of them. A name that ENDS
37
+ // in the secret word is the secret; token_expires_at is only about one.
38
+ // Singular only: `tokens` and `input_tokens` count what an AI call used -
39
+ // found on the next blind test, where api_usage.tokens (an integer) was
40
+ // named as a secret.
41
+ [/\b(password|passwd|pass_hash|password_hash)\b|(^|_)(password|passwd|pwd|password_hash)$/i, 'passwords'],
42
+ [/\b(token|api_key|apikey|secret|private_key|access_key)\b|(^|_)(token|secret|(api|private|access|secret|signing|service|encryption)_?key)$/i, 'access tokens and secrets'],
43
+ [/\b(card|card_number|cvv|iban|account_number|upi)\b/i, 'payment details'],
44
+ // A code that grants something: whoever reads a moderator_code can become a
45
+ // moderator. Found on a blind test, added straight in production and readable
46
+ // by anyone - reported only as "2 rows".
47
+ [/^(?:\w+_)?(moderator|invite|invitation|access|reset|verification|recovery|admin|join|auth|login|magic|security|otp)_code$|^(otp|pin)$/i, 'secret codes'],
48
+ ];
49
+
50
+ // Who holds power in the app. Not a secret in itself, but a list of exactly
51
+ // which accounts are worth attacking.
52
+ const PRIVILEGE = [/^(is_admin|is_superuser|is_super_admin|is_staff|is_moderator|is_owner|role|roles|permissions)$/i, 'who is an admin'];
53
+
54
+ const MONEY = [/\b(amount|total|price|balance|salary|revenue|invoice)\b/i, 'amounts of money'];
55
+
56
+ // Each kind named with the columns that showed it: "secret codes
57
+ // (moderator_code)" can be found in the table; "secret codes" has to be
58
+ // looked for.
59
+
60
+ /**
61
+ * A function's name as SQL: "fn", or "other"."fn" for one in a schema of the
62
+ * app's other than the one scanned (blind round 17, DB-04) - "other.fn" in
63
+ * one pair of quotes would name a function that does not exist.
64
+ */
65
+ function quotedFunction(name) {
66
+ const text = String(name || '');
67
+ const dot = text.indexOf('.');
68
+ const quote = (part) => '"' + part.split('"').join('""') + '"';
69
+ return dot > 0 ? quote(text.slice(0, dot)) + '.' + quote(text.slice(dot + 1)) : quote(text);
70
+ }
71
+ function matched(columns, table) {
72
+ const names = (columns || []).map(String);
73
+ const found = [];
74
+ for (const [pattern, label] of table) {
75
+ const hits = names.filter((column) => pattern.test(column));
76
+ if (hits.length) found.push(label + ' (' + hits.join(', ') + ')');
77
+ }
78
+ return found;
79
+ }
80
+
81
+ /** What a table holds, in the words a person would use. */
82
+ function readContents(columns) {
83
+ const identity = matched(columns, IDENTITY);
84
+ const secrets = matched(columns, SECRETS);
85
+ const money = matched(columns, [MONEY]);
86
+ const privilege = matched(columns, [PRIVILEGE]);
87
+ return { identity: identity, secrets: secrets, money: money, privilege: privilege };
88
+ }
89
+
90
+ /** Everything worth naming, worst first. */
91
+ function heldIn(contents) {
92
+ return contents.secrets.concat(contents.identity, contents.privilege || [], contents.money);
93
+ }
94
+
95
+ /**
96
+ * A table anyone can read that may be meant to be public (blind round 17,
97
+ * DEC-03): nothing on a row says whose it is - no person, no team - and no
98
+ * column looks personal, secret, money or a role. A price list, a blog. Said
99
+ * as a question, never as HIGH. Not a view: a view can leave out the column
100
+ * that says whose the rows underneath are.
101
+ */
102
+ function publicByDesign(finding) {
103
+ if (finding.kind !== 'exposed' || finding.nobodys !== true || finding.isView || finding.countOnly) return false;
104
+ // On a row that is nobody's, a bare name is a thing's name (a product's),
105
+ // and a price is what the list is for. A person's email, phone, address or
106
+ // username, a secret, or who is an admin, still is not public by design -
107
+ // a waitlist of emails is a leak whoever's rows they are.
108
+ const columns = (finding.columns || []).filter((c) => !/^(name|title)$/i.test(String(c)));
109
+ const contents = readContents(columns);
110
+ return contents.identity.length + contents.secrets.length + (contents.privilege || []).length === 0;
111
+ }
112
+
113
+ /** A list, written the way a person writes one. */
114
+ function listOf(items) {
115
+ const list = items.filter(Boolean);
116
+ if (!list.length) return '';
117
+ if (list.length === 1) return list[0];
118
+ return list.slice(0, -1).join(', ') + ' and ' + list[list.length - 1];
119
+ }
120
+
121
+ /** One of a table's things: workspaces -> workspace, companies -> company. */
122
+ /** A role, quoted, with its article: a "viewer", an "analyst". */
123
+ function aRole(role) {
124
+ return (/^[aeiou]/i.test(String(role)) ? 'an "' : 'a "') + role + '"';
125
+ }
126
+
127
+ /** The first letter made a capital, for the start of a sentence. */
128
+ function capital(text) {
129
+ return text.charAt(0).toUpperCase() + text.slice(1);
130
+ }
131
+
132
+ /** The same, with its article: a workspace, an organization. */
133
+ function aOne(table) {
134
+ const one = oneOf(table);
135
+ return (/^[aeiou]/i.test(one) ? 'an ' : 'a ') + one;
136
+ }
137
+
138
+ function oneOf(table) {
139
+ return String(table || 'team').replace(/ies$/, 'y').replace(/s$/, '');
140
+ }
141
+
142
+ /* --------------------------------------------------------------------------
143
+ How bad it is.
144
+ -------------------------------------------------------------------------- */
145
+
146
+ /**
147
+ * Severity, decided by what could be read rather than by how it was read.
148
+ *
149
+ * Only two levels on purpose. A scale of five invites the reader to skim past
150
+ * the bottom three, and everything the night shift reports is something that
151
+ * should be fixed - there is no such thing as a low finding here.
152
+ */
153
+ function severityOf(finding, contents) {
154
+ if (finding.kind === 'recursive') {
155
+ // Nothing leaks and nothing is written: the request fails. Serious,
156
+ // because the app is broken for whoever it fails for, but not a break-in.
157
+ return 'HIGH';
158
+ }
159
+ // A member who can make themselves the owner: never the design, and the
160
+ // owner's reach is everything - the one role finding that is confirmed.
161
+ if (finding.kind === 'role' && finding.promote) return 'CRITICAL';
162
+ if (finding.kind === 'role' || finding.kind === 'teamread' || finding.kind === 'bucket') {
163
+ // Something to check, never a confirmed break: whether a viewer may edit,
164
+ // or a bucket may be public, is the owner's decision. So never CRITICAL.
165
+ return 'HIGH';
166
+ }
167
+ if (finding.kind === 'conflict' || finding.kind === 'searchpath' || finding.kind === 'nullcheck' || finding.kind === 'noinsert') {
168
+ // Read from how a function or a column is declared. A refused upsert
169
+ // breaks the app for whoever calls it; an open search_path and an empty
170
+ // value are for the owner to weigh. None of them hands a row to anybody.
171
+ return 'HIGH';
172
+ }
173
+ if (finding.kind === 'privileged') {
174
+ // Not proven, so never CRITICAL: this is a reach that exists, not a break
175
+ // that was demonstrated. A function anon can call that also writes is worth
176
+ // more attention than one that only reads, but both stay HIGH and both
177
+ // carry "verification required" - the person has to say whether the anon
178
+ // call was intended.
179
+ return 'HIGH';
180
+ }
181
+ if (finding.kind === 'orphaned') {
182
+ // Nothing is exposed and nothing is destroyed, so this is not the same
183
+ // order of thing as a table anyone can empty. It stays serious because
184
+ // the data it strands is data somebody asked to have deleted.
185
+ return 'HIGH';
186
+ }
187
+ if (finding.kind === 'writable') {
188
+ // Deleting and rewriting are unrecoverable in a way that reading is not:
189
+ // a leak is bad, but a customer table somebody emptied is gone. Being able
190
+ // only to add rows is serious and not the same thing.
191
+ const canDestroy = (finding.can || []).some((what) => what === 'delete' || what === 'change');
192
+ return canDestroy ? 'CRITICAL' : 'HIGH';
193
+ }
194
+ if (finding.kind === 'duplicated') {
195
+ // Judged by what the duplicate lets somebody do, not by what else sits in
196
+ // the table. A second row holding the same session token is critical even
197
+ // if the table has nothing personal in it at all.
198
+ if (finding.expectation === 'credential') return 'CRITICAL';
199
+ // Critical only while this column could be the login itself. When the
200
+ // database has Supabase's auth.users, sign-in goes through that table,
201
+ // which keeps every email unique - so a duplicate here is a copy that
202
+ // disagrees with itself, not two accounts sharing one login. Found on a
203
+ // real app: the report said "two accounts can answer to the same login"
204
+ // about a profiles table the login never read.
205
+ if (finding.expectation === 'identity') return finding.loginElsewhere ? 'HIGH' : 'CRITICAL';
206
+ return 'HIGH';
207
+ }
208
+ // Maybe public on purpose: a question for the owner, not a HIGH.
209
+ if (publicByDesign(finding)) return 'MEDIUM';
210
+ if (contents.secrets.length) return 'CRITICAL';
211
+ if (contents.identity.length) return 'CRITICAL';
212
+ return 'HIGH';
213
+ }
214
+
215
+ /* --------------------------------------------------------------------------
216
+ Saying it.
217
+ -------------------------------------------------------------------------- */
218
+
219
+ /**
220
+ * Why the door was open - and these two are genuinely different problems.
221
+ *
222
+ * Row level security switched off is an oversight. Switched on with a policy
223
+ * that lets everybody through is worse, because the dashboard shows the table
224
+ * as protected and the person has already been told they are safe. Saying which
225
+ * one it is saves them looking in the wrong place.
226
+ */
227
+ function causeOf(finding) {
228
+ if (finding.kind === 'recursive') {
229
+ return {
230
+ short: 'a rule on it looks the same table up again',
231
+ long:
232
+ 'A row level security rule on this table checks something by reading ' +
233
+ 'this same table - usually "is this person a member?" asked of the ' +
234
+ 'members table itself. Reading the table means applying the rule, and ' +
235
+ 'applying the rule means reading the table, so Postgres stops the ' +
236
+ 'request with an error instead of going round forever. Any other ' +
237
+ 'table whose rule asks this one fails the same way.',
238
+ };
239
+ }
240
+ if (finding.kind === 'orphaned') {
241
+ return {
242
+ short: 'nothing in the database ties the two tables together',
243
+ long:
244
+ 'There is no foreign key between these two tables, so the database has '+
245
+ 'no idea they are related. It will not refuse a row that points at '+
246
+ 'nothing, it will not stop somebody being deleted while their rows are '+
247
+ 'still here, and it will never find these rows again afterwards.',
248
+ };
249
+ }
250
+ if (finding.kind === 'writable' && finding.isView) {
251
+ // Written through a view (blind round 17, DB-09): the rules on the table
252
+ // are right, and the view walks past them.
253
+ return {
254
+ short: 'a view writes with its creator\'s rights, not the visitor\'s',
255
+ long:
256
+ 'This is a view, and a view runs as whoever created it unless it is ' +
257
+ 'told otherwise. A plain view over one table can be written through, ' +
258
+ 'so adding, changing and deleting rows of "' + finding.base + '" through ' +
259
+ 'it is checked against the creator, not against the person asking - and ' +
260
+ 'the rules you wrote on "' + finding.base + '" do not apply here at all.',
261
+ };
262
+ }
263
+ if (finding.kind === 'writable') {
264
+ if (finding.rlsEnabled) {
265
+ // Which rule let it through, from what is actually on the table rather
266
+ // than from an assumption.
267
+ //
268
+ // This branch used to say, of every writable table with row level
269
+ // security on, that its rule "covers every command rather than only
270
+ // reading". Measured against a table whose only policy was written FOR
271
+ // INSERT, that sentence was false twice over: the rule covered one
272
+ // command, and there was no rule letting anybody read at all. The
273
+ // finding was proved; the reason was invented. A report that overstates
274
+ // once is believed never again, so the reason is now read, not guessed.
275
+ const rules = (finding.rules || []).map((rule) => String(rule).toUpperCase());
276
+ const WRITTEN_FOR = { add: 'INSERT', change: 'UPDATE', 'delete': 'DELETE' };
277
+ const IN_WORDS = { INSERT: 'adding rows', UPDATE: 'changing rows', DELETE: 'deleting rows' };
278
+ const named = [];
279
+ for (const move of finding.can || []) {
280
+ const command = WRITTEN_FOR[move];
281
+ if (command && rules.includes(command) && !named.includes(command)) named.push(command);
282
+ }
283
+
284
+ if (rules.includes('ALL')) {
285
+ return {
286
+ short: 'the rule on it covers every command, not only reading',
287
+ long:
288
+ 'The table has row level security switched on, but the rule attached ' +
289
+ 'to it is written FOR ALL - so the same rule that decides who may ' +
290
+ 'see a row also decides who may add, change and delete one, and it ' +
291
+ 'is letting this through.',
292
+ };
293
+ }
294
+
295
+ if (named.length) {
296
+ return {
297
+ short: 'the rule you wrote for ' + listOf(named.map((c) => IN_WORDS[c])) +
298
+ ' lets everybody through',
299
+ long:
300
+ 'The table has row level security switched on, and there is a rule on ' +
301
+ 'it written for ' + listOf(named.map((c) => IN_WORDS[c])) + '. That ' +
302
+ 'rule is the one letting this through: it does not check who is ' +
303
+ 'asking, so it accepts the request from a stranger exactly as it ' +
304
+ 'would from the person the row belongs to.',
305
+ };
306
+ }
307
+
308
+ // Row level security on, and no permissive rule that names this write.
309
+ // It should not have been possible, and saying which rule did it would
310
+ // be inventing one. What is certain is what was done, and that is all
311
+ // this says.
312
+ return {
313
+ short: 'row level security is on, and something let this through anyway',
314
+ long:
315
+ 'The table has row level security switched on, and none of the rules on ' +
316
+ 'it are written for this kind of write - so this should have been ' +
317
+ 'refused and was not. Check whether the role your app connects as owns ' +
318
+ 'the table or has BYPASSRLS, because either of those goes round every ' +
319
+ 'rule you have written.',
320
+ };
321
+ }
322
+ return {
323
+ short: 'row level security was never switched on',
324
+ long:
325
+ 'Row level security has never been switched on for this table. Supabase ' +
326
+ 'grants insert, update and delete on the public schema by default, and ' +
327
+ 'row level security is what takes them back - so until it is on, those ' +
328
+ 'permissions are simply in force.',
329
+ };
330
+ }
331
+ if (finding.isView) {
332
+ // The one people are caught by, because everything looks right. The table
333
+ // is protected, the policy is correct, the dashboard is green - and the
334
+ // view hands the rows out anyway.
335
+ return {
336
+ short: 'a view reads with its creator\'s rights, not the visitor\'s',
337
+ long:
338
+ 'This is a view, and a view runs as whoever created it unless it is ' +
339
+ 'told otherwise. So any row level security on the tables underneath is ' +
340
+ 'checked against the creator, not against the person asking - and the ' +
341
+ 'rules you wrote on those tables do not apply here at all.',
342
+ };
343
+ }
344
+ if (finding.kind === 'duplicated') {
345
+ return {
346
+ short: 'nothing in the database makes it unique',
347
+ long:
348
+ 'There is no unique constraint and no unique index on this column, so ' +
349
+ 'the database has no way to refuse the second one. Checking in your app ' +
350
+ 'code before inserting does not close this: between the check and the ' +
351
+ 'insert, the other request has already been accepted.',
352
+ };
353
+ }
354
+ if (finding.rlsEnabled) {
355
+ return {
356
+ short: 'the rule that guards it lets everybody through',
357
+ long:
358
+ 'The table has row level security switched on, so it looks protected, ' +
359
+ 'but the rule attached to it allows every request. That is why nothing ' +
360
+ 'in your dashboard flags it.',
361
+ };
362
+ }
363
+ return {
364
+ short: 'it has no protection switched on at all',
365
+ long:
366
+ 'Row level security has never been switched on for this table, so every ' +
367
+ 'rule you might have written elsewhere does not apply to it.',
368
+ };
369
+ }
370
+
371
+ /** What a caller could do, written the way a person would say it. */
372
+ const WRITE_WORDS = { add: 'add rows to', change: 'change rows in', delete: 'delete rows from' };
373
+
374
+ /**
375
+ * The error a looping rule produces, as Postgres words it. A rule that reads
376
+ * its own table directly is caught as recursion and named; one that reaches
377
+ * it through a helper function runs out of stack, and Postgres names nothing.
378
+ */
379
+ function loopError(finding) {
380
+ return finding.deep
381
+ ? '"stack depth limit exceeded"'
382
+ : '"infinite recursion detected in policy for relation ' + finding.table + '"';
383
+ }
384
+
385
+ /** Who a looping rule fails for, said the way the headline and body need it. */
386
+ function loopVictims(finding) {
387
+ const callers = finding.callers || [];
388
+ if (callers.includes('anyone') && callers.includes('signed-in')) return 'everyone, signed in or not,';
389
+ if (callers.includes('anyone')) return 'visitors who are not logged in';
390
+ return 'signed-in users';
391
+ }
392
+
393
+ function headlineFor(finding) {
394
+ if (finding.kind === 'recursive') {
395
+ return 'A rule on your ' + finding.table + ' table refers to itself, so ' + loopVictims(finding) +
396
+ ' get an error instead of data.';
397
+ }
398
+ if (finding.kind === 'orphaned') {
399
+ return 'Your ' + finding.table + ' table can point at a ' +
400
+ finding.parent.replace(/s$/, '') + ' that does not exist.';
401
+ }
402
+ if (finding.kind === 'writable') {
403
+ // Worst first, because the headline is often all that gets read.
404
+ const order = ['delete', 'change', 'add'];
405
+ const does = order.filter((what) => (finding.can || []).includes(what)).map((what) => WRITE_WORDS[what]);
406
+ return (finding.who === 'anyone' ? 'Anyone' : 'Any signed-in customer') + ' can ' +
407
+ listOf(does) + ' your ' + (finding.isView ? finding.base + ' table through your ' + finding.table + ' view.' : finding.table + ' table.');
408
+ }
409
+ if (finding.kind === 'duplicated') {
410
+ return 'Your ' + finding.table + ' table lets the same ' + finding.column + ' exist twice.';
411
+ }
412
+ if (finding.kind === 'privileged') {
413
+ return 'Anyone on the internet can call your ' + finding.fn + ' function, and it runs with full rights.';
414
+ }
415
+ if (finding.kind === 'searchpath') {
416
+ return 'Your ' + finding.fn + ' function runs with full rights but does not pin its search_path.';
417
+ }
418
+ if (finding.kind === 'conflict') {
419
+ return 'Your ' + finding.fn + ' function fails every time it saves: Postgres refuses its ON CONFLICT.';
420
+ }
421
+ if (finding.kind === 'nullcheck') {
422
+ return 'Your ' + finding.table + ' table accepts rows with no ' + finding.column + ' at all.';
423
+ }
424
+ if (finding.kind === 'noinsert') {
425
+ return 'Nobody can add a row to your ' + finding.table + ' table: its rule for adding rows checks nothing, and Postgres reads that as no.';
426
+ }
427
+ if (finding.kind === 'role' && finding.takeover) {
428
+ return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can take another member\'s place in your ' +
429
+ finding.table + ' table - and with it their role.';
430
+ }
431
+ if (finding.kind === 'role' && finding.movein) {
432
+ return 'Anyone signed in can make themselves ' + aRole(finding.toRole) + ' of any ' + oneOf(finding.parent) +
433
+ ' by moving their own row in your ' + finding.table + ' table into it.';
434
+ }
435
+ if (finding.kind === 'role' && finding.promote) {
436
+ return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can make themselves ' + aRole(finding.toRole) +
437
+ ' by changing their own row in your ' + finding.table + ' table.';
438
+ }
439
+ if (finding.kind === 'role') {
440
+ const order = ['delete', 'change', 'add'];
441
+ const does = order.filter((what) => (finding.can || []).includes(what)).map((what) => WRITE_WORDS[what]);
442
+ return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can ' + listOf(does) + ' your ' +
443
+ finding.table + ' table.';
444
+ }
445
+ if (finding.kind === 'teamread') {
446
+ return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can read your ' + finding.table +
447
+ ' table, which holds ' + listOf(finding.secrets || []) + '.';
448
+ }
449
+ if (finding.kind === 'bucket' && finding.noRule && (finding.writers || []).length) {
450
+ return 'Your ' + finding.table + ' storage bucket is public, though only signed-in members may put files in it.';
451
+ }
452
+ if (finding.kind === 'bucket' && finding.noRule) {
453
+ return 'Your ' + finding.table + ' storage bucket is public, and its name says its files are not for everyone.';
454
+ }
455
+ if (finding.kind === 'bucket') {
456
+ return 'Your ' + finding.table + ' storage bucket is public, but a rule says only some people may read it.';
457
+ }
458
+ if (publicByDesign(finding)) {
459
+ return 'Anyone can read your ' + finding.table + ' table - is it public by design?';
460
+ }
461
+ if (finding.kind === 'exposed') {
462
+ return 'Your ' + finding.table + ' ' + (finding.isView ? 'view' : 'table') + ' can be read by anyone.';
463
+ }
464
+ // The table name goes in front of the sentence rather than inside it. Put
465
+ // inside, a table called `customers` produced "One customer can read another
466
+ // customer's customers", which is the kind of line that loses a reader.
467
+ if (finding.asRole) {
468
+ return capital(aRole(finding.asRole)) + ' of any ' + oneOf(finding.parent) + ' can read every other ' + oneOf(finding.parent) +
469
+ '\'s rows in your ' + finding.table + ' table.';
470
+ }
471
+ if (finding.tenant) return 'Your ' + finding.table + ' table lets a signed-in user read another organization\'s rows.';
472
+ return 'Your ' + finding.table + ' table lets one customer read another one\'s rows.';
473
+ }
474
+
475
+ /** What a duplicate of this particular column actually costs the person. */
476
+ const COST_OF_A_DUPLICATE = {
477
+ credential: 'A credential that matches more than one row means one secret opens more than one account.',
478
+ identity: 'Two accounts can answer to the same login, and a password reset has to guess which one to send.',
479
+ code: 'A one-time code that can exist twice can be redeemed twice.',
480
+ };
481
+
482
+ // Said instead of the login sentence when sign-in happens in auth.users.
483
+ const COST_OF_A_COPIED_IDENTITY =
484
+ 'Signing in is not affected: your app signs people in through auth.users, ' +
485
+ 'which keeps each email unique. But anything that looks a person up by this ' +
486
+ 'column can get two rows back and pick the wrong one.';
487
+
488
+ function costOf(finding) {
489
+ if (finding.expectation === 'identity' && finding.loginElsewhere) return COST_OF_A_COPIED_IDENTITY;
490
+ return COST_OF_A_DUPLICATE[finding.expectation] || '';
491
+ }
492
+
493
+ function bodyFor(finding, contents) {
494
+ const holds = listOf(heldIn(contents));
495
+ const rowWord = finding.readable === 1 ? 'row' : 'rows';
496
+
497
+ if (finding.kind === 'recursive') {
498
+ // Said as what happened to the requests, and then what it cost this
499
+ // report: the tables behind the error could not be tested for leaks.
500
+ const reads = finding.reads || [];
501
+ const callers = finding.callers || [];
502
+ const as = [];
503
+ if (callers.includes('anyone')) as.push('a visitor who is not logged in');
504
+ if (callers.includes('signed-in')) as.push('a signed-in user');
505
+ const one = reads.length === 1;
506
+ return (
507
+ 'Using ' + listOf(reads) + ' on a copy of your app as ' + as.join(' and as ') +
508
+ ', every request was refused with ' + loopError(finding) +
509
+ (finding.deep ? ' - a rule on ' + finding.table + ' calls a helper that reads ' + finding.table + ' again, which runs the rule again' : '') +
510
+ '. Every page in your app that uses ' + (one ? 'that table' : 'those tables') +
511
+ ' fails the same way for ' + loopVictims(finding).replace(/,$/, '') + '. It also means I could ' +
512
+ 'not test ' + (one ? 'it' : 'them') + ' for leaks - those attacks are listed at the end.'
513
+ );
514
+ }
515
+
516
+ if (finding.kind === 'orphaned') {
517
+ // Said as the thing a person will actually meet: a customer asks to be
518
+ // deleted, and their rows are still here afterwards.
519
+ return (
520
+ 'I added a row to ' + finding.table + ' naming a ' +
521
+ finding.parent.replace(/s$/, '') + " that is not in your " + finding.parent +
522
+ ' table, and it was accepted. So when one of them is deleted, the rows here ' +
523
+ 'stay behind pointing at nobody - and a signup or checkout that stops halfway ' +
524
+ 'leaves the same thing. The row I added was undone straight away.'
525
+ );
526
+ }
527
+
528
+ if (finding.kind === 'writable') {
529
+ const holds = listOf(heldIn(contents));
530
+ const who = finding.who === 'anyone'
531
+ ? 'Without logging in and without an account, I '
532
+ : 'Signed in as one of your customers, I ';
533
+ const did = [];
534
+ if ((finding.can || []).includes('add')) did.push('added a row');
535
+ if ((finding.can || []).includes('change')) {
536
+ did.push('changed ' + (finding.changed.change || 1) + " of another customer's rows");
537
+ }
538
+ if ((finding.can || []).includes('delete')) {
539
+ did.push('deleted ' + (finding.changed.delete || 1) + " of another customer's rows");
540
+ }
541
+ // Said immediately, because "I deleted your rows" is a sentence that stops
542
+ // somebody reading, and they need the next line more than the first.
543
+ return who + listOf(did) + ' on a copy of your app' +
544
+ (finding.isView ? ', through the ' + finding.table + ' view - the same writes on the ' + finding.base +
545
+ ' table itself did not get as far' : '') +
546
+ (holds ? ', in a table holding ' + holds : '') +
547
+ '. Every one of those was undone straight away - nothing on the copy was ' +
548
+ 'kept, and your live app was never touched.';
549
+ }
550
+
551
+ if (finding.kind === 'role' && finding.takeover) {
552
+ // Not a matter of taste like a viewer who may edit: taking somebody
553
+ // else's membership is never what a role is for.
554
+ const place = oneOf(finding.parent);
555
+ return (
556
+ 'I put one of my fake people into the other one\'s ' + place + ' as ' + aRole(finding.who) +
557
+ ' in ' + finding.via + '.' + finding.roleColumn + ' and, signed in as them, changed ' +
558
+ finding.via + '.' + finding.personColumn + ' on the other person\'s own membership row to their own id - so ' +
559
+ 'they held that member\'s place and role, on a copy of your app. Before joining, they could not. It was ' +
560
+ 'undone straight away. A member taking over another member\'s row is never something a role should be able ' +
561
+ 'to do, even where members may edit some of a membership.'
562
+ );
563
+ }
564
+
565
+ if (finding.kind === 'role' && finding.movein) {
566
+ // Never the design: nobody was invited, and the team they moved into is
567
+ // somebody else's.
568
+ const place = oneOf(finding.parent);
569
+ return (
570
+ 'Signed in as one of my fake people - not a member of the other one\'s ' + place + ', never invited - I ' +
571
+ 'changed their own row in ' + finding.via + ': ' + listOf((finding.moved || []).map((c) => finding.via + '.' + c)) +
572
+ ' to the other ' + place + ', and ' + finding.via + '.' + finding.roleColumn + ' to "' + finding.toRole +
573
+ '" - on a copy of your app. It went through, and was undone straight away. Anybody who can sign up and has a ' +
574
+ place + ' of their own can do this to every ' + place + ' whose id they learn, and from then on has every right ' +
575
+ aRole(finding.toRole) + ' has there.'
576
+ );
577
+ }
578
+ if (finding.kind === 'role' && finding.promote) {
579
+ // Never the design: a member who can raise their own role has every
580
+ // right the top role has, in every table that trusts the role.
581
+ const place = oneOf(finding.parent);
582
+ return (
583
+ 'I put one of my fake people into the other one\'s ' + place + ' as ' + aRole(finding.who) +
584
+ ' in ' + finding.via + '.' + finding.roleColumn + ' and, signed in as them, changed ' + finding.via + '.' +
585
+ finding.roleColumn + ' on their own membership row to "' + finding.toRole + '" - on a copy of your app. It was ' +
586
+ 'undone straight away. From then on they would have every right ' + aRole(finding.toRole) + ' has, in every ' +
587
+ 'table and function that trusts that role.'
588
+ );
589
+ }
590
+
591
+ if (finding.kind === 'role') {
592
+ // Said as what was done, and then whose decision it is: a viewer who may
593
+ // edit is a hole in one app and the design of another.
594
+ const did = [];
595
+ const rows = (n) => n + (n === 1 ? ' row' : ' rows');
596
+ if ((finding.can || []).includes('add')) did.push('added ' + rows((finding.changed || {}).add || 1));
597
+ if ((finding.can || []).includes('change')) did.push('changed ' + rows((finding.changed || {}).change || 1));
598
+ if ((finding.can || []).includes('delete')) did.push('deleted ' + rows((finding.changed || {}).delete || 1));
599
+ const place = oneOf(finding.parent);
600
+ // The roles below it that were tried first and could not, when there are
601
+ // any: a role in the middle is named because the ones under it held.
602
+ const below = (finding.below || []).map((r) => '"' + r + '"');
603
+ return (
604
+ 'I put one of my fake people into the other one\'s ' + place + ' as ' + aRole(finding.who) +
605
+ ' - ' + (below.length ? 'a role' : 'the lowest role') + ' in ' + finding.via + '.' + finding.roleColumn +
606
+ ' - and, signed in as them, I ' + listOf(did) + ' in ' + finding.table + ' for that ' + place +
607
+ ', on a copy of your app. Before joining, they could not.' +
608
+ (below.length ? ' As ' + listOf(below).replace(/ and /, ' or ') + ' they could not either, and no rule on this ' +
609
+ 'table lists ' + aRole(finding.who).replace(/^an? /, '') + ' among the roles allowed to make that change.' : '') +
610
+ ' Every one of those was undone straight away. Whether ' + aRole(finding.who) + ' may do this is your ' +
611
+ 'decision: if it is meant to, nothing needs fixing; if not, the rules on this table let them do more.'
612
+ );
613
+ }
614
+
615
+ if (finding.kind === 'teamread') {
616
+ // Said as what was read and by whom, and which roles below it could not:
617
+ // a dispatcher seeing a key may be the design, a viewer rarely is.
618
+ const place = oneOf(finding.parent);
619
+ const n = finding.readable || 1;
620
+ const below = (finding.below || []).map((r) => '"' + r + '"');
621
+ return (
622
+ 'I put one of my fake people into the other one\'s ' + place + ' as ' + aRole(finding.who) + ' and, signed ' +
623
+ 'in as them, read ' + n + (n === 1 ? ' row' : ' rows') + ' of ' + finding.table + ' for that ' + place +
624
+ ', on a copy of your app - including ' + listOf(finding.secrets || []) + '. Before joining, they could not.' +
625
+ (below.length ? ' As ' + listOf(below).replace(/ and /, ' or ') + ' they could not read it either.' : '') +
626
+ ' Nothing was changed. Whether ' + aRole(finding.who) + ' should see these is your decision: if not, a read ' +
627
+ 'rule on this table lets them.'
628
+ );
629
+ }
630
+
631
+ if (finding.kind === 'bucket' && finding.noRule) {
632
+ return (
633
+ 'In Supabase Storage the bucket "' + finding.bucket + '" is set to public, so anyone with a ' +
634
+ 'file\'s link can download it without logging in - the rules on storage.objects are not asked. ' +
635
+ ((finding.writers || []).length
636
+ ? 'No rule says who may read it, but ' + listOf((finding.writers || []).map((r) => '"' + r + '"')) +
637
+ ' let only signed-in members put files in or take them out - so the app treats these files as its ' +
638
+ 'members\' own, while anyone holding a link can download them. '
639
+ : 'No rule says who may read it, but its name says what it holds, and that does not sound like ' +
640
+ 'something for everyone. ') +
641
+ 'I only read the bucket\'s setting and its rules; I did not open or list ' +
642
+ 'any file. Whether it should be public is yours to say.'
643
+ );
644
+ }
645
+ if (finding.kind === 'bucket') {
646
+ // Said as what was read, never as a file taken: no file was opened.
647
+ return (
648
+ 'In Supabase Storage the bucket "' + finding.bucket + '" is set to public, so anyone with a ' +
649
+ 'file\'s link can download it without logging in - the rules on storage.objects are not asked. ' +
650
+ 'You also wrote ' + ((finding.rules || []).length === 1 ? 'a rule' : 'rules') + ' saying who may read it (' +
651
+ listOf(finding.rules || []) + '), so it was probably meant to be private. I only read the ' +
652
+ 'bucket\'s setting and its rules; I did not open or list any file. Whether it should be public ' +
653
+ 'is yours to say.'
654
+ );
655
+ }
656
+
657
+ if (finding.kind === 'searchpath') {
658
+ return (
659
+ 'This function is SECURITY DEFINER, so it runs as whoever created it and the row level ' +
660
+ 'security on the tables it touches is skipped for the caller - and ' +
661
+ (finding.anon ? 'anyone, even without an account,' : 'every signed-in user') + ' can call it. ' +
662
+ 'It does not set its search_path, so every table or function inside it written without a ' +
663
+ 'schema (scans rather than public.scans) is looked up wherever the caller\'s path points ' +
664
+ 'first. Somebody able to put a table or function earlier on that path would make it use ' +
665
+ 'theirs, with the owner\'s rights. I did not call it; this is read from how it is declared.'
666
+ );
667
+ }
668
+ if (finding.kind === 'conflict') {
669
+ return (
670
+ finding.fn + ' inserts into ' + finding.target + ' with ON CONFLICT (' + finding.on + '). ' +
671
+ 'Postgres needs a unique index or constraint on exactly those columns to know what a ' +
672
+ 'conflict is, and ' + finding.target + ' has none it can use, so it refuses the statement - ' +
673
+ 'the function errors on every call instead of saving. ' +
674
+ (finding.partialWhere
675
+ ? 'There is a unique index on those columns, but it is partial (WHERE ' + finding.partialWhere +
676
+ '), and a partial index is only used when the ON CONFLICT repeats its WHERE. '
677
+ : '') +
678
+ 'I did not call the function: I asked Postgres to plan that one statement on the copy, and ' +
679
+ 'it answered "there is no unique or exclusion constraint matching the ON CONFLICT specification".'
680
+ );
681
+ }
682
+ if (finding.kind === 'nullcheck') {
683
+ return (
684
+ finding.column + ' has a CHECK that allows only ' +
685
+ listOf((finding.allowed || []).map((v) => '\'' + v + '\'')) + ' - but a CHECK only refuses a ' +
686
+ 'value it can show is wrong, and an empty value (NULL) is never shown wrong, so it passes. ' +
687
+ 'The column is not NOT NULL and has no default, so a row saved without a ' + finding.column +
688
+ ' is kept with none, and anything that shows or sorts by it has to cope. I did not insert ' +
689
+ 'anything; this is read from how the column is declared.'
690
+ );
691
+ }
692
+ if (finding.kind === 'noinsert') {
693
+ return (
694
+ 'Row level security is on for ' + finding.table + ', and the only ' + ((finding.rules || []).length === 1 ? 'rule' : 'rules') +
695
+ ' for adding rows (' + listOf((finding.rules || []).map((r) => '"' + r + '"')) + ') ' +
696
+ ((finding.rules || []).length === 1 ? 'has' : 'have') + ' neither USING nor WITH CHECK. A rule with no ' +
697
+ 'expression is not "allow everything": Postgres treats it as false, so every INSERT - from the app, ' +
698
+ 'and from any function that runs as the user - is refused with "new row violates row-level security ' +
699
+ 'policy". If your app adds ' + finding.table + ' rows, that feature is broken for everyone. This is read ' +
700
+ 'from how the rule is written; I did not insert anything.'
701
+ );
702
+ }
703
+ if (finding.kind === 'privileged') {
704
+ // Said as what is true, not as what was done - nothing was executed. The
705
+ // reach is real; whether it is a mistake is the person's to say, so the
706
+ // wording asks rather than accuses.
707
+ return (
708
+ 'This function is SECURITY DEFINER, so it runs as whoever created it, not ' +
709
+ 'as the visitor calling it - which means the row level security on the tables ' +
710
+ 'it touches is checked against the owner and skipped for the caller. It is ' +
711
+ 'granted to anon, the role a visitor with no account uses, so anyone on the ' +
712
+ 'internet can call it' +
713
+ (finding.writes ? ' - and it writes to your data, so a caller can change rows the rules would otherwise protect.'
714
+ : ', reaching whatever it reads past the rules on those tables.') +
715
+ ' I did not call it: whether an open function like this is intended is ' +
716
+ 'something only you can confirm.' +
717
+ (finding.nullSlips
718
+ ? ' Its first lines do check the caller\'s role - but with NOT IN or <>, and somebody with no role ' +
719
+ 'there gets NULL back, and NULL NOT IN (...) is not true: the IF never raises, so a signed-in ' +
720
+ 'stranger gets through as if they had passed.'
721
+ : '') +
722
+ (finding.hasFixedSearchPath ? ''
723
+ : ' It also does not pin its search_path, so a caller can point the names ' +
724
+ 'inside it at their own objects.')
725
+ );
726
+ }
727
+ if (finding.kind === 'duplicated') {
728
+ // Said as what was done, not as what it implies. Two connections, one
729
+ // moment, both accepted, and here is the count afterwards.
730
+ return (
731
+ 'I opened two connections to a copy of your app and inserted the same ' +
732
+ finding.column + ' from both at the same moment. Both were accepted, so there are ' +
733
+ 'now ' + finding.copies + ' rows holding the identical value. ' + costOf(finding)
734
+ ).trim();
735
+ }
736
+
737
+ if (publicByDesign(finding)) {
738
+ return (
739
+ 'Without logging in and without an account, I read ' + finding.readable + ' ' +
740
+ (finding.readable === 1 ? 'row' : 'rows') + ' of this table on a copy of your app. Nothing in it says whose a ' +
741
+ 'row is - no user, no team - and none of its columns look personal or secret, so it may be meant to be ' +
742
+ 'public, like a price list or a blog. If it is, nothing needs to change.'
743
+ );
744
+ }
745
+ if (finding.kind === 'exposed') {
746
+ // Two different proofs, said as two different things. The copy-based scan
747
+ // signed in with the anon key and pulled the rows out; the outside check
748
+ // (outside.js) asked the live app for the count alone and never pulled a
749
+ // row, so it may not claim it did.
750
+ if (finding.countOnly) {
751
+ const many = finding.readable === 1 ? 'is 1 row' : 'are ' + finding.readable + ' rows';
752
+ return (
753
+ 'Anyone on the internet, without logging in and without an account, can read ' +
754
+ 'this table. I asked your live app as a stranger and it let me - there ' + many +
755
+ ' in it. I asked only for the count, so I never pulled a row out; a real ' +
756
+ 'attacker would have taken them.'
757
+ );
758
+ }
759
+ return (
760
+ 'Anyone on the internet, without logging in and without an account, can read ' +
761
+ // Read back the next morning, "just now" would be untrue: it was the
762
+ // nightly run, hours earlier.
763
+ 'this table. ' + (finding.fromNight ? 'The nightly run did it on a copy and got back ' : 'I did it myself just now and got back ') +
764
+ finding.readable + ' ' + rowWord +
765
+ (holds ? ', including ' + holds : '') + '.'
766
+ );
767
+ }
768
+ if (finding.asRole) {
769
+ // Blind round 15: a role check in a read rule that was never tied to the
770
+ // row's own organization.
771
+ return (
772
+ 'I made one of my fake people ' + aRole(finding.asRole) + ' of their own ' + oneOf(finding.parent) + ' (' + finding.via + '.' +
773
+ finding.roleColumn + ') and, signed in as them, asked for the other ' + oneOf(finding.parent) + '\'s rows in ' + finding.table +
774
+ ' and got ' + finding.readable + ' back' + (holds ? ', including ' + holds : '') + '. With an ordinary role they got none. ' +
775
+ 'So anybody who is ' + aRole(finding.asRole) + ' anywhere - which in most apps means anybody who creates their own ' +
776
+ oneOf(finding.parent) + ' - can read every ' + oneOf(finding.parent) + '\'s rows here.'
777
+ );
778
+ }
779
+ if (finding.tenant) {
780
+ return (
781
+ 'Signed in as someone in a different organization, I asked for another organization\'s rows and got ' +
782
+ finding.readable + ' of them back' + (holds ? ', including ' + holds : '') +
783
+ '. Anyone with an account can read every organization\'s rows.'
784
+ );
785
+ }
786
+ return (
787
+ 'Signed in as one customer, I asked for another customer\'s rows and got ' +
788
+ finding.readable + ' of them back' + (holds ? ', including ' + holds : '') +
789
+ '. Every customer you have can do this to every other customer.'
790
+ );
791
+ }
792
+
793
+ /**
794
+ * The thing they paste.
795
+ *
796
+ * Written to the tool they already use, which means it has to carry its own
797
+ * context - the assistant on the other end has not seen any of this. It names
798
+ * the table, says what is wrong in terms of the code rather than the symptom,
799
+ * and asks for the same mistake to be swept up elsewhere, because it is almost
800
+ * never in only one place.
801
+ */
802
+ /** Wraps one paragraph to a width that reads in a chat box and a terminal. */
803
+ function wrapTo(text, width) {
804
+ const words = String(text).split(/\s+/);
805
+ const out = [];
806
+ let current = '';
807
+ for (const word of words) {
808
+ if ((current + ' ' + word).trim().length > width) {
809
+ out.push(current.trim());
810
+ current = word;
811
+ } else {
812
+ current = (current + ' ' + word).trim();
813
+ }
814
+ }
815
+ if (current) out.push(current.trim());
816
+ return out;
817
+ }
818
+
819
+ /**
820
+ * Whether the app signs people in through Supabase: any foreign key to
821
+ * auth.users, or any rule that asks auth.uid() or auth.jwt(). Decided for the
822
+ * whole app, not table by table. Found on a blind test: appointments points
823
+ * at profiles, which points at auth.users - no key of its own and no rule
824
+ * asking auth.uid(), so it was told the app "signs people in its own way"
825
+ * when it plainly used Supabase's. Read from the original schema, not the
826
+ * copy - the copy points those keys at a stand-in. A table missing from the
827
+ * map is unknown, which the prompts treat as tied.
828
+ */
829
+ function authTiesOf(plan) {
830
+ const tables = (plan && plan.tables) || [];
831
+ const keyed = tables.some((table) => (table.constraints || []).some((c) =>
832
+ c.kind === 'f' && /references\s+"?auth"?\s*\.\s*"?users"?\s*\(/i.test(String(c.definition || ''))));
833
+ const asked = ((plan && plan.policies) || []).some((policy) =>
834
+ /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(String(policy.qual || '') + ' ' + String(policy.with_check || '')));
835
+ return new Map(tables.map((table) => [table.name, keyed || asked]));
836
+ }
837
+
838
+ /**
839
+ * The fix for a table no rule can tie to the signed-in user, or null when one
840
+ * can. Two ways that happens. The app has its own login: found on a real app,
841
+ * "compare the owner to the id of the signed-in user" cannot be done there -
842
+ * auth.uid() is empty for everybody - and an assistant that writes it anyway
843
+ * locks the app out of its own table. Or the table has no column saying whose
844
+ * row it is - a waitlist, a contact form - so there is nothing to compare.
845
+ */
846
+ // A column that says which organization a row belongs to. Such a table is
847
+ // not "nobody's" - its rows are a team's.
848
+ const TENANT_COLUMN = /^(org_id|organization_id|workspace_id|team_id|tenant_id|company_id)$/i;
849
+
850
+ function serverOnlyLines(finding) {
851
+ const table = finding.table;
852
+ let why;
853
+ // Found on a fix test: api_usage has org_id, and the prompt said it had no
854
+ // column saying whose row it is. The assistant applying it had to correct
855
+ // the prompt; a less careful one would have locked the table away from the
856
+ // members who use it.
857
+ // A row an organization owns names its organization even when the column is
858
+ // not called organization_id: the attack found which key it is.
859
+ const team = finding.authTied !== false &&
860
+ (finding.tenant ? finding.owner : (finding.columns || []).find((c) => TENANT_COLUMN.test(String(c))));
861
+ const maker = finding.owner || finding.ownedBy;
862
+ // A team's row that also says who made it - created_by beside workspace_id.
863
+ // Found on a benchmark: the prompt said to compare created_by to auth.uid()
864
+ // for reading, which hides every teammate's rows and breaks the shared
865
+ // view the table exists for. Who made a row decides who may write it
866
+ // under their name, not who may see it.
867
+ if (team && !finding.tenant && finding.owned !== false && maker) {
868
+ return [
869
+ 'Rows in "' + table + '" belong to a team ("' + team + '"), and "' + maker + '" only records who ' +
870
+ 'made each one. So reading should be decided by team membership: let a signed-in user read a ' +
871
+ 'row when they are a member of that row\'s "' + team + '" - checked through your membership ' +
872
+ 'table, or a helper function that asks it - and give logged-out visitors nothing. Do not ' +
873
+ 'compare "' + maker + '" to auth.uid() for reading: that hides teammates\' rows from each other ' +
874
+ 'and breaks the feature.',
875
+ '',
876
+ 'Write separate rules for reading, inserting, updating and deleting. Inserting: WITH CHECK that ' +
877
+ 'the user is a member of the row\'s "' + team + '" AND "' + maker + '" = auth.uid(), so nobody ' +
878
+ 'writes a row under somebody else\'s name or into a team they are not in. Updating and ' +
879
+ 'deleting: the membership check in USING (and WITH CHECK for updating), and decide whether any ' +
880
+ 'member may do it or only the row\'s "' + maker + '" or an admin. If only your server writes ' +
881
+ 'this table, add no write rules at all.',
882
+ '',
883
+ ];
884
+ }
885
+ const tenant = (finding.tenant || finding.owned === false) && team;
886
+ if (tenant) {
887
+ return [
888
+ 'Rows in "' + table + '" belong to an organization ("' + tenant + '"), not to one person. So the ' +
889
+ 'rule should let a signed-in user in only when they are a member of that row\'s organization - ' +
890
+ 'checked through your membership table, or a helper function that asks it - and give logged-out ' +
891
+ 'visitors nothing.',
892
+ '',
893
+ 'Write separate rules for reading, inserting, updating and deleting, each with that membership ' +
894
+ 'check (USING for reading, updating and deleting, WITH CHECK for inserting and updating), and ' +
895
+ 'decide whether every member should be able to write or only admins. If only your server ' +
896
+ 'writes this table, add no write rules at all.',
897
+ '',
898
+ ];
899
+ }
900
+ if (finding.authTied === false) {
901
+ why = 'Nothing in "' + table + '" points at auth.users and no rule on it uses auth.uid(), so your ' +
902
+ 'app signs people in its own way. A rule comparing a column to the signed-in user cannot ' +
903
+ 'work here - auth.uid() is empty for everyone - and adding one would lock your own app out.';
904
+ } else if (finding.owned === false) {
905
+ why = '"' + table + '" has no column that says whose row it is, so a rule tying rows to the ' +
906
+ 'signed-in user has nothing to compare against.';
907
+ } else {
908
+ return null;
909
+ }
910
+ return [
911
+ why,
912
+ '',
913
+ 'If only your server reads and writes this table: keep row level security on, drop the rules ' +
914
+ 'that let everyone in, and add none for anon or authenticated. Have your server use the ' +
915
+ 'service_role key, which skips these rules - and keep that key on the server only, never ' +
916
+ 'in the browser or in the app\'s public code.',
917
+ '',
918
+ 'If the browser needs to read or write part of it directly, tell me which part first, so we ' +
919
+ 'write the narrowest rule for exactly that.',
920
+ '',
921
+ ];
922
+ }
923
+
924
+ /**
925
+ * The opening of a rules fix. "Switch row level security on" is said only
926
+ * when it is off: told it about a table where it was already on, the person
927
+ * looks for a switch that is already flipped and misses the rule that is the
928
+ * real problem.
929
+ */
930
+ function switchOn(finding) {
931
+ return finding.rlsEnabled
932
+ ? 'Row level security is already on for this table, so the rules on it are the problem: '
933
+ : 'Switch row level security on for this table, then ';
934
+ }
935
+
936
+ /**
937
+ * One prompt for everything wrong with one table's rules - read and write,
938
+ * strangers and customers - because it is one rule to replace, not four.
939
+ */
940
+ function groupPromptFor(members) {
941
+ const lead = members.find((m) => m.kind === 'writable') || members[0];
942
+ const cause = causeOf(lead);
943
+ const table = lead.table;
944
+ const owner = (members.find((m) => m.owner) || {}).owner;
945
+ const ownerWords = owner ? '"' + owner + '"' : 'the column that says who each row belongs to';
946
+ const read = members.find((m) => m.kind === 'exposed');
947
+ const writers = members.filter((m) => m.kind === 'writable');
948
+ const anyoneWrites = writers.find((w) => w.who === 'anyone');
949
+ const customersWrite = writers.find((w) => w.who !== 'anyone');
950
+ const what = [];
951
+ if (read) what.push('read');
952
+ if (writers.length) what.push('written');
953
+ const who = read || anyoneWrites ? 'anyone who is not logged in' : 'any signed-in user, including other users\' rows';
954
+ const did = [];
955
+ if (read) did.push('read ' + read.readable + ' ' + (read.readable === 1 ? 'row' : 'rows'));
956
+ const moves = [];
957
+ for (const w of writers) for (const m of w.can || []) if (!moves.includes(m)) moves.push(m);
958
+ for (const m of ['add', 'change', 'delete']) {
959
+ if (moves.includes(m)) did.push({ add: 'added a row', change: 'changed rows', delete: 'deleted rows' }[m]);
960
+ }
961
+ const lines = [
962
+ 'My app has a security problem.',
963
+ '',
964
+ 'The "' + table + '" table can be ' + what.join(' and ') + ' by ' + who +
965
+ ', because ' + cause.short + '. I proved it on a copy: I ' + listOf(did) + '.' +
966
+ (anyoneWrites && customersWrite ? ' Signed-in users can do the same.' : ''),
967
+ '',
968
+ ].concat(
969
+ serverOnlyLines(lead) || [
970
+ switchOn(lead) + 'remove the rule that lets everyone in, and write separate rules for reading, ' +
971
+ 'inserting, updating and deleting. A rule written FOR ALL covers far more than reading.',
972
+ '',
973
+ 'Each of them should compare ' + ownerWords + ' to the id of the signed-in user (auth.uid()): ' +
974
+ 'USING for reading, updating and deleting, WITH CHECK for inserting and updating, so nobody ' +
975
+ 'reads, changes or writes a row under somebody else\'s name. Give logged-out visitors no rule ' +
976
+ 'at all unless they truly need one.',
977
+ '',
978
+ ],
979
+ ['Then check every other table for the same thing.'],
980
+ );
981
+ return lines
982
+ .map((paragraph) => (paragraph ? wrapTo(paragraph, 72) : ['']))
983
+ .reduce((all, part) => all.concat(part), [])
984
+ .join('\n');
985
+ }
986
+
987
+ function fixPromptFor(finding) {
988
+ const cause = causeOf(finding);
989
+ const owner = finding.owner ? '"' + finding.owner + '"' : 'the column that says who each row belongs to';
990
+
991
+ const lines = publicByDesign(finding)
992
+ ? [
993
+ 'My app may have a security problem - please check it rather than assume it.',
994
+ '',
995
+ 'The "' + finding.table + '" table can be read by anyone who is not logged in. Nothing in it says whose a ' +
996
+ 'row is, and none of its columns look personal or secret, so it may be meant to be public.',
997
+ '',
998
+ 'First tell me: should logged-out visitors be able to read "' + finding.table + '"? If yes, change nothing.',
999
+ '',
1000
+ 'If not: ' + (finding.rlsEnabled ? 'row level security is already on, so remove the rule that lets everyone read it' :
1001
+ 'switch row level security on for it') + ', and add a rule for reading that lets in only the people who should ' +
1002
+ 'see it - signed-in users (TO authenticated), or the members of whoever the rows are for. Change nothing else.',
1003
+ ]
1004
+ : finding.kind === 'recursive'
1005
+ ? [
1006
+ 'My app has a broken database rule.',
1007
+ '',
1008
+ 'A row level security policy on the "' + finding.table + '" table reads "' + finding.table +
1009
+ '" itself' + (finding.deep ? ', through a helper function it calls,' : '') +
1010
+ ' so Postgres stops every request that touches it with ' + loopError(finding) + '. I saw it when using ' +
1011
+ listOf((finding.reads || []).map((t) => '"' + t + '"')) + '.',
1012
+ '',
1013
+ 'Show me the policies on "' + finding.table + '" and on every table whose policy reads "' +
1014
+ finding.table + '", and find the lookup that goes back into "' + finding.table + '".',
1015
+ '',
1016
+ 'Move that lookup into a SECURITY DEFINER function that answers only about the signed-in ' +
1017
+ 'user - for example is_member_of(org uuid) returning whether auth.uid() is in that ' +
1018
+ 'organization - with SET search_path fixed, owned by the table\'s owner so it reads the ' +
1019
+ 'table without the rule applying again. Grant EXECUTE on it to authenticated only. Then ' +
1020
+ 'make the policies call that function instead of reading "' + finding.table + '" directly.',
1021
+ '',
1022
+ 'Keep each policy exactly as strict as it was meant to be: members see only their own ' +
1023
+ 'organization\'s rows, and logged-out visitors get nothing. Do not replace it with USING ' +
1024
+ '(true) to make the error go away.',
1025
+ '',
1026
+ 'Then sign in as an ordinary user and open the pages that use these tables, and run this ' +
1027
+ 'check again - the tables it could not test will be tested this time.',
1028
+ ]
1029
+ : finding.kind === 'role' && finding.takeover
1030
+ ? [
1031
+ 'My app has a permissions problem - a member can take over another member\'s place.',
1032
+ '',
1033
+ 'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
1034
+ finding.roleColumn + '" is "' + finding.who + '" can update another member\'s row in "' + finding.via +
1035
+ '" and set "' + finding.personColumn + '" to their own id - taking that member\'s place and role, an ' +
1036
+ 'admin\'s or the owner\'s included. This was proved on a copy of the database.',
1037
+ '',
1038
+ 'Fix the rules on "' + finding.via + '" that cover UPDATE or ALL: nobody may change "' + finding.personColumn +
1039
+ '" or the team a membership belongs to on an existing row - adding or removing members is how membership ' +
1040
+ 'changes. Let only the roles that manage the team (usually owner and admin) update membership rows at ' +
1041
+ 'all, check that in WITH CHECK as well as USING, and never use WITH CHECK (true). If members only need to ' +
1042
+ 'change something of their own, allow exactly that column on their own row and nothing else.',
1043
+ '',
1044
+ 'Then check every function that updates "' + finding.via + '" for the same thing.',
1045
+ ]
1046
+ : finding.kind === 'role' && finding.movein
1047
+ ? [
1048
+ 'My app has a security problem - anyone signed in can make themselves ' + aRole(finding.toRole) + ' of any ' +
1049
+ oneOf(finding.parent) + '.',
1050
+ '',
1051
+ 'In my database, a signed-in user can update their own row in "' + finding.via + '" and change ' +
1052
+ listOf((finding.moved || []).map((c) => '"' + c + '"')) + ' to another ' + oneOf(finding.parent) +
1053
+ ' - one they were never invited to - and "' + finding.roleColumn + '" to "' + finding.toRole + '". The rule only ' +
1054
+ 'checks that the row is theirs, not what they change in it. This was proved on a copy of the database.',
1055
+ '',
1056
+ 'Fix the rules on "' + finding.via + '" that cover UPDATE or ALL: nobody may change ' +
1057
+ listOf((finding.moved || []).map((c) => '"' + c + '"')) + ' on an existing row, and a member must never be ' +
1058
+ 'able to change "' + finding.roleColumn + '" on their own row. Joining happens only by an invitation that ' +
1059
+ 'somebody who manages that ' + oneOf(finding.parent) + ' made. Let only the roles that manage the team change ' +
1060
+ 'membership rows, checked in WITH CHECK as well as USING - for example with a helper that reads "' + finding.via +
1061
+ '" for auth.uid() and the row\'s own ' + listOf((finding.moved || []).map((c) => '"' + c + '"')) + '.',
1062
+ '',
1063
+ 'Then check every function that updates "' + finding.via + '" for the same thing.',
1064
+ ]
1065
+ : finding.kind === 'role' && finding.promote
1066
+ ? [
1067
+ 'My app has a security problem - a member can make themselves ' + aRole(finding.toRole) + '.',
1068
+ '',
1069
+ 'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
1070
+ finding.roleColumn + '" is "' + finding.who + '" can update their own row in "' + finding.via + '" and set "' +
1071
+ finding.roleColumn + '" to "' + finding.toRole + '". The rule only checks that the row is theirs, not what ' +
1072
+ 'they change in it. This was proved on a copy of the database.',
1073
+ '',
1074
+ 'Fix the rules on "' + finding.via + '" that cover UPDATE or ALL: a member must never be able to change "' +
1075
+ finding.roleColumn + '" on their own row. Let only the roles that manage the team (usually owner and admin) ' +
1076
+ 'change roles, checked in WITH CHECK as well as USING - for example with a helper that reads "' + finding.via +
1077
+ '" for auth.uid() - and make sure nobody can raise anyone above their own role. If members need to edit ' +
1078
+ 'something else of their own, allow exactly those columns and not "' + finding.roleColumn + '".',
1079
+ '',
1080
+ 'Then check every function that updates "' + finding.via + '" for the same thing.',
1081
+ ]
1082
+ : finding.kind === 'role'
1083
+ ? [
1084
+ 'My app may have a permissions problem - please check it rather than assume it.',
1085
+ '',
1086
+ 'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
1087
+ finding.roleColumn + '" is "' + finding.who + '" can ' +
1088
+ listOf((finding.can || []).map((what) => ({ add: 'add', change: 'change', delete: 'delete' })[what])) +
1089
+ ' rows of that ' + oneOf(finding.parent) + ' in the "' + finding.table + '" table. This was proved ' +
1090
+ 'on a copy of the database: ' + aRole(finding.who) + ' was added, and the writes went through.',
1091
+ '',
1092
+ 'First tell me: should ' + aRole(finding.who) + ' be able to do that? If yes, change nothing.',
1093
+ '',
1094
+ 'If not, make the rules on "' + finding.table + '" that cover UPDATE, DELETE, INSERT or ALL check ' +
1095
+ 'the member\'s role as well as membership - for example with a helper that reads "' + finding.via +
1096
+ '" for auth.uid() and the roles allowed to edit. Postgres lets a write through if ANY permissive ' +
1097
+ 'policy allows it, so a strict rule beside a loose one does nothing: list every such policy on "' +
1098
+ finding.table + '" and narrow or drop the loose one. Keep the read rule as it is if ' +
1099
+ aRole(finding.who) + ' should still see these rows.',
1100
+ '',
1101
+ 'Then check every other table where members can write for the same thing.',
1102
+ ]
1103
+ : finding.kind === 'teamread'
1104
+ ? [
1105
+ 'My app may have a permissions problem - please check it rather than assume it.',
1106
+ '',
1107
+ 'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
1108
+ finding.roleColumn + '" is "' + finding.who + '" can read the "' + finding.table + '" table of that ' +
1109
+ oneOf(finding.parent) + ', including ' + listOf((finding.secrets || []).map((c) => '"' + c + '"')) +
1110
+ '. This was proved on a copy of the database: ' + aRole(finding.who) + ' was added, and the rows came back.',
1111
+ '',
1112
+ 'First tell me: should ' + aRole(finding.who) + ' see these? If yes, change nothing.',
1113
+ '',
1114
+ 'If not, make the rules on "' + finding.table + '" that cover SELECT or ALL check the member\'s role as ' +
1115
+ 'well as membership. Postgres lets a read through if ANY permissive policy allows it, so a strict rule ' +
1116
+ 'beside a loose one does nothing: list every such policy on "' + finding.table + '" and narrow or drop ' +
1117
+ 'the loose one. If members need the other columns, a view or function that leaves the secret ones out ' +
1118
+ 'is often the better fix.',
1119
+ '',
1120
+ 'Then check every other table that holds tokens, keys, secrets or payloads for the same thing.',
1121
+ ]
1122
+ : finding.kind === 'bucket'
1123
+ ? [
1124
+ 'My app may have a storage problem - please check it rather than assume it.',
1125
+ '',
1126
+ 'The Supabase Storage bucket "' + finding.bucket + '" is public. Anyone with a file\'s URL can ' +
1127
+ 'download it without logging in' + (finding.noRule && (finding.writers || []).length
1128
+ ? ', and no rule on storage.objects says who may read it - though ' +
1129
+ listOf((finding.writers || []).map((r) => '"' + r + '"')) + ' let only signed-in members upload or delete ' +
1130
+ 'its files, which suggests they are not meant for everyone.'
1131
+ : finding.noRule
1132
+ ? ', and no rule on storage.objects says who may read it - its name suggests its files are not ' +
1133
+ 'meant for everyone.'
1134
+ : ', and the storage.objects ' +
1135
+ ((finding.rules || []).length === 1 ? 'policy that names' : 'policies that name') + ' this bucket (' +
1136
+ listOf((finding.rules || []).map((r) => '"' + r + '"')) + ') ' +
1137
+ ((finding.rules || []).length === 1 ? 'is' : 'are') + ' not checked for public URLs - so ' +
1138
+ ((finding.rules || []).length === 1 ? 'it protects' : 'they protect') + ' nothing while it is public.'),
1139
+ '',
1140
+ 'First tell me: is every file in this bucket meant to be downloadable by anyone? If yes, change nothing.',
1141
+ '',
1142
+ 'If not, make the bucket private (in the Supabase dashboard: Storage, edit the bucket, turn Public ' +
1143
+ 'off - or UPDATE storage.buckets SET public = false WHERE id = \'' + finding.bucket + '\'). Then ' +
1144
+ 'change the app to fetch these files with signed URLs (createSignedUrl) instead of getPublicUrl, ' +
1145
+ 'or the links it shows will stop working.',
1146
+ '',
1147
+ finding.noRule
1148
+ ? 'Once it is private, add a read rule on storage.objects for this bucket that lets in only the ' +
1149
+ 'people a file belongs to - without one, nobody but your server can read them.'
1150
+ : 'Keep the read rule: once the bucket is private it is what decides who gets a file. Check that it ' +
1151
+ 'only lets in the right people.',
1152
+ '',
1153
+ 'Then look at every other bucket for the same thing.',
1154
+ ]
1155
+ : finding.kind === 'searchpath'
1156
+ ? [
1157
+ 'My app may have a security weakness - please check it rather than assume it.',
1158
+ '',
1159
+ 'The database function "' + finding.fn + '(' + (finding.args || '') + ')" is SECURITY DEFINER ' +
1160
+ '(it runs with its owner\'s rights) and does not set a search_path, so names inside it ' +
1161
+ 'without a schema are looked up along the caller\'s path.',
1162
+ '',
1163
+ 'Fix only this: add SET search_path = \'\' to the function\'s definition, and write every ' +
1164
+ 'table, view and function it uses with its schema - public.scans, not scans; auth.uid() ' +
1165
+ 'stays as it is. Keep its body, its checks and its grants exactly as they are.',
1166
+ '',
1167
+ 'Then call it once the way the app does, to make sure it still works, and look at every ' +
1168
+ 'other SECURITY DEFINER function for the same thing.',
1169
+ ]
1170
+ : finding.kind === 'conflict'
1171
+ ? [
1172
+ 'My app has a broken database function.',
1173
+ '',
1174
+ 'The function "' + finding.fn + '(' + (finding.args || '') + ')" runs INSERT INTO ' +
1175
+ finding.target + ' ... ON CONFLICT (' + finding.on + '), and Postgres refuses it with ' +
1176
+ '"there is no unique or exclusion constraint matching the ON CONFLICT specification" - so ' +
1177
+ 'it fails on every call.',
1178
+ '',
1179
+ finding.partialWhere
1180
+ ? 'The unique index on (' + finding.on + ') is partial: WHERE ' + finding.partialWhere + '. ' +
1181
+ 'Repeat that condition in the function right after the conflict columns: ON CONFLICT (' +
1182
+ finding.on + ') WHERE ' + finding.partialWhere + ' DO ... - and keep the rest of the ' +
1183
+ 'statement as it is. Do not drop the index\'s WHERE: it is what lets rows without a value exist.'
1184
+ : 'Make the ON CONFLICT match a unique rule that exists: either add a unique constraint on ' +
1185
+ finding.target + ' (' + finding.on + ') if those columns really must be unique together, or ' +
1186
+ 'change the conflict columns to an existing unique key. Tell me which you chose and why.',
1187
+ '',
1188
+ 'Then call the function twice with the same values: the second call must update the row, ' +
1189
+ 'not error and not add a second one.',
1190
+ ]
1191
+ : finding.kind === 'nullcheck'
1192
+ ? [
1193
+ 'My app may have a data problem - please check it rather than assume it.',
1194
+ '',
1195
+ 'In the "' + finding.table + '" table, the column "' + finding.column + '" has a CHECK that ' +
1196
+ 'allows only ' + listOf((finding.allowed || []).map((v) => '\'' + v + '\'')) + ', but the column ' +
1197
+ 'can be empty (NULL), and a CHECK lets NULL through.',
1198
+ '',
1199
+ 'First tell me: should every row have a ' + finding.column + '? If an empty one is meant to be ' +
1200
+ 'allowed, change nothing and say so.',
1201
+ '',
1202
+ 'If every row should have one: set the empty ones first (UPDATE "' + finding.table + '" SET "' +
1203
+ finding.column + '" = <the right value> WHERE "' + finding.column + '" IS NULL - ask me what ' +
1204
+ 'the right value is), then ALTER TABLE "' + finding.table + '" ALTER COLUMN "' + finding.column +
1205
+ '" SET NOT NULL. Change nothing else.',
1206
+ ]
1207
+ : finding.kind === 'noinsert'
1208
+ ? [
1209
+ 'My app may have a bug - please check it rather than assume it.',
1210
+ '',
1211
+ 'In my database, the rule' + ((finding.rules || []).length === 1 ? ' ' : 's ') +
1212
+ listOf((finding.rules || []).map((r) => '"' + r + '"')) + ' on "' + finding.table + '" for INSERT ' +
1213
+ ((finding.rules || []).length === 1 ? 'has' : 'have') + ' no WITH CHECK, and there is no other rule that ' +
1214
+ 'allows adding rows. Postgres treats a missing check as false, so nobody can add a row to "' + finding.table +
1215
+ '" - not even through a function that runs as the user.',
1216
+ '',
1217
+ 'First tell me: is adding rows to "' + finding.table + '" meant to work for signed-in users? If only your ' +
1218
+ 'server adds them, change nothing.',
1219
+ '',
1220
+ 'If users should be able to add them: give the rule a WITH CHECK that says who may - for example that ' +
1221
+ 'the user is a member of the row\'s organization (through your membership table or a helper) and that ' +
1222
+ 'the column naming who created it equals auth.uid(). Never use WITH CHECK (true): that lets anyone add ' +
1223
+ 'rows anywhere. Then add one row through the app to make sure it works.',
1224
+ ]
1225
+ : finding.kind === 'privileged'
1226
+ ? [
1227
+ 'My app may have a security problem - please check it rather than assume it.',
1228
+ '',
1229
+ 'The database function "' + finding.fn + '(' + (finding.args || '') + ')" is SECURITY ' +
1230
+ 'DEFINER, so it runs with its owner\'s rights and ignores row level security. It is ' +
1231
+ 'granted EXECUTE to "anon", so anyone who is not logged in can call it' +
1232
+ (finding.writes ? ', and it writes to tables.' : '.'),
1233
+ '',
1234
+ 'First tell me: is it meant to be callable by logged-out visitors? If it is not, ' +
1235
+ 'REVOKE EXECUTE ON FUNCTION ' + quotedFunction(finding.fn) + '(' + (finding.args || '') + ') FROM anon, PUBLIC.',
1236
+ '',
1237
+ // Found on a blind test (Northstar): get_recent_findings(org id) was
1238
+ // the app's own RPC for signed-in members, and "grant it to
1239
+ // service_role" would have broken the page that calls it.
1240
+ 'Then decide who should run it. If signed-in users of the app call it, keep it granted to ' +
1241
+ 'authenticated and add a check at its top that the caller - auth.uid() - may act on what ' +
1242
+ 'they passed in, for example that they are a member (or an admin) of the organization ' +
1243
+ 'whose id it takes, and RAISE an exception otherwise. Only if nothing but your own server ' +
1244
+ 'calls it, grant it to service_role alone.',
1245
+ '',
1246
+ ].concat(
1247
+ finding.nullSlips
1248
+ ? [
1249
+ 'Its role check lets through anyone with no role at all: the helper returns NULL for them, and ' +
1250
+ 'NULL NOT IN (...) - or NULL <> ... - is not true, so the IF never raises. Write the check so ' +
1251
+ 'NULL cannot pass, for example IF coalesce(<the helper>(...), \'none\') NOT IN (...) THEN RAISE, ' +
1252
+ 'or IF <the helper>(...) IS NULL OR <the helper>(...) NOT IN (...) THEN RAISE - and check every ' +
1253
+ 'other function that uses the same helper the same way.',
1254
+ '',
1255
+ ]
1256
+ : [],
1257
+ finding.hasFixedSearchPath
1258
+ ? []
1259
+ : [
1260
+ 'It also does not set a fixed search_path. Add SET search_path = pg_catalog, public (or ' +
1261
+ 'the schemas it truly needs) so a caller cannot make it resolve names to their own objects.',
1262
+ '',
1263
+ ],
1264
+ [
1265
+ 'If it is meant to be public, keep it, but make sure everything inside it checks what the ' +
1266
+ 'caller is allowed to see or change itself - the table rules will not do it here.',
1267
+ '',
1268
+ 'Then look at every other SECURITY DEFINER function for the same grant.',
1269
+ ],
1270
+ )
1271
+ : finding.kind === 'orphaned'
1272
+ ? [
1273
+ 'My app has a data problem.',
1274
+ '',
1275
+ 'The "' + finding.table + '" table has a "' + finding.column +
1276
+ '" column that is meant to point at "' + finding.parent + '".' +
1277
+ ' There is no foreign key between them, so the database does not know they ' +
1278
+ 'are related. I proved it by adding a row naming one that does not exist, ' +
1279
+ 'and it was accepted.',
1280
+ '',
1281
+ 'Add a foreign key from "' + finding.table + '"."' + finding.column +
1282
+ '" to "' + finding.parent + '"."' + finding.parentKey + '".',
1283
+ '',
1284
+ 'Decide on purpose what should happen when the parent row is deleted: ON ' +
1285
+ 'DELETE CASCADE if the children should go too, ON DELETE SET NULL if they ' +
1286
+ 'should stay without an owner, or nothing at all if the delete should be ' +
1287
+ 'refused while children exist.',
1288
+ '',
1289
+ 'There may already be rows pointing at nothing, and the constraint will not ' +
1290
+ 'be created until those are dealt with. Find them first.',
1291
+ ]
1292
+ : finding.kind === 'writable' && finding.isView
1293
+ ? [
1294
+ 'My app has a security problem.',
1295
+ '',
1296
+ 'The "' + finding.table + '" view lets ' +
1297
+ (finding.who === 'anyone' ? 'anyone who is not logged in' : 'any signed-in user') + ' write to the "' +
1298
+ finding.base + '" table past its row level security: I ' +
1299
+ (finding.can || []).map((what) => ({ add: 'added a row', change: 'changed rows', delete: 'deleted rows' })[what])
1300
+ .join(', ') + ' through the view, and the same writes on the table itself did not get as far. A view runs with ' +
1301
+ 'the rights of whoever created it, so the rules on "' + finding.base + '" are never checked against the person asking.',
1302
+ '',
1303
+ 'Fix it with ALTER VIEW "' + finding.table + '" SET (security_invoker = true), so reads and writes through the ' +
1304
+ 'view run as the person asking and the rules on "' + finding.base + '" apply. If the app only reads through it, ' +
1305
+ 'also REVOKE INSERT, UPDATE, DELETE ON "' + finding.table + '" FROM anon, authenticated. Change nothing else.',
1306
+ '',
1307
+ 'Then check every other view in the app for the same thing.',
1308
+ ]
1309
+ : finding.kind === 'writable'
1310
+ ? [
1311
+ 'My app has a security problem.',
1312
+ '',
1313
+ 'The "' + finding.table + '" table can be written to by ' +
1314
+ (finding.who === 'anyone' ? 'anyone who is not logged in' : 'any signed-in user, including other users rows') +
1315
+ ', because ' + cause.short + '. I proved it: I ' +
1316
+ (finding.can || []).map((what) => ({ add: 'added a row', change: 'changed rows', delete: 'deleted rows' })[what])
1317
+ .join(', ') + '.',
1318
+ '',
1319
+ ].concat(
1320
+ serverOnlyLines(finding) || [
1321
+ switchOn(finding) + 'write separate rules for reading, inserting, updating and deleting. ' +
1322
+ 'A rule written FOR SELECT does not cover writes, and a rule written FOR ALL covers ' +
1323
+ 'far more than reading.',
1324
+ '',
1325
+ 'For insert and update, use WITH CHECK comparing ' + owner +
1326
+ ' to the id of the signed-in user, so nobody can write a row under somebody ' +
1327
+ "else's name. For update and delete, add USING with the same comparison: " +
1328
+ 'with USING (true) left on the update, anyone can take a row that is not ' +
1329
+ 'theirs and rewrite it as their own.',
1330
+ '',
1331
+ ],
1332
+ [
1333
+ 'Then check every other table for the same thing - a table with no row level ' +
1334
+ 'security on it is writable by default.',
1335
+ ],
1336
+ )
1337
+ : finding.kind === 'duplicated'
1338
+ ? [
1339
+ 'My app has a security problem.',
1340
+ '',
1341
+ 'Two rows in the "' + finding.table + '" table can hold the same "' + finding.column +
1342
+ '". I proved it by inserting the same value from two connections at the same ' +
1343
+ 'moment, and both were accepted.',
1344
+ '',
1345
+ // First, because it has to happen before anything is changed. Learned
1346
+ // by applying the short version of this prompt literally to a real app:
1347
+ // the constraint cannot be created while duplicates exist, and an
1348
+ // assistant told only "add a constraint" may delete rows to get there.
1349
+ 'First, find the rows that already share a value and show them to me. Do not ' +
1350
+ 'delete or merge any of them without asking me: the rule below cannot be ' +
1351
+ 'created while they exist, and deleting rows to make it pass loses real data.',
1352
+ '',
1353
+ (finding.expectation === 'identity'
1354
+ ? 'Then add a unique index on lower("' + finding.column + '"), so that "' + finding.table +
1355
+ '"."' + finding.column + '" is unique whatever the case - Ann@example.com and ' +
1356
+ 'ann@example.com are the same person.'
1357
+ : 'Then add a unique constraint on "' + finding.table + '"."' + finding.column + '".') +
1358
+ ' It has to be in the database itself. Checking in application code before inserting ' +
1359
+ 'is not enough - between the check and the insert, the other request has already gone in.',
1360
+ '',
1361
+ // Said because the obvious fix is wrong for multi-tenant apps, and being
1362
+ // told to drop a legitimate design would cost them more than the bug.
1363
+ 'If the same value is allowed to repeat for different owners, make it cover both ' +
1364
+ 'columns together rather than leaving it off.',
1365
+ '',
1366
+ ].concat(
1367
+ [
1368
+ 'Find everything that writes this column - app code, and any database trigger or ' +
1369
+ 'function, such as one that copies each new sign-up into this table - and make ' +
1370
+ 'sure each one handles "this value already exists" instead of failing. If people ' +
1371
+ 'can edit this column themselves, decide whether they should: once it is unique, ' +
1372
+ 'someone who puts another person\'s value in first will block that person.',
1373
+ '',
1374
+ ],
1375
+ finding.expectation === 'identity' && finding.loginElsewhere
1376
+ ? [
1377
+ 'Sign-in uses auth.users, so this column is only a copy of the email there. ' +
1378
+ 'Consider not letting people edit it at all and keeping it in step with auth.users.',
1379
+ '',
1380
+ ]
1381
+ : [],
1382
+ [
1383
+ 'Afterwards, sign up a brand-new account and edit an existing profile to make sure ' +
1384
+ 'both still work, and run this check again.',
1385
+ '',
1386
+ 'Then look for the same missing constraint on every other table and fix those too.',
1387
+ ],
1388
+ )
1389
+ : finding.asRole
1390
+ ? [
1391
+ 'My app has a security problem.',
1392
+ '',
1393
+ 'In "' + finding.table + '", anyone who is "' + finding.asRole + '" in their own ' + oneOf(finding.parent) + ' can read the rows ' +
1394
+ 'of every ' + oneOf(finding.parent) + '. I proved it on a copy: as "' + finding.asRole + '" of one ' + oneOf(finding.parent) +
1395
+ ', I read ' + finding.readable + ' row(s) of another.',
1396
+ '',
1397
+ 'A rule on "' + finding.table + '" checks the caller\'s role without tying it to the row - for example ' +
1398
+ 'exists (select 1 from ' + finding.via + ' m where m.' + finding.roleColumn + ' = \'' + finding.asRole + '\') with no ' +
1399
+ 'condition that m is the signed-in user\'s membership of THIS row\'s ' + oneOf(finding.parent) + '. Fix every rule on "' +
1400
+ finding.table + '": a role check must say m.user_id = auth.uid() AND m.<' + oneOf(finding.parent) + ' id> = the row\'s ' +
1401
+ oneOf(finding.parent) + ' id - or call a helper that does. Keep membership as the way in; do not replace it with USING (true).',
1402
+ '',
1403
+ 'Then check every other rule and helper that checks a role for the same missing link.',
1404
+ ]
1405
+ : finding.isView
1406
+ ? [
1407
+ 'My app has a security problem.',
1408
+ '',
1409
+ 'The "' + finding.table + '" view can be read by anyone who is not logged in. ' +
1410
+ 'A view runs with the rights of whoever created it, so the row level security ' +
1411
+ 'on the tables underneath is never checked against the person asking.',
1412
+ '',
1413
+ 'Fix it by recreating the view with security_invoker set on, so it runs as the ' +
1414
+ 'visitor and the rules on the underlying tables apply - and make sure those ' +
1415
+ 'tables actually have those rules.',
1416
+ '',
1417
+ 'Then check every other view in the app for the same thing.',
1418
+ ]
1419
+ : [
1420
+ 'My app has a security problem.',
1421
+ '',
1422
+ 'The "' + finding.table + '" table ' +
1423
+ (finding.kind === 'exposed'
1424
+ ? 'can be read by anyone who is not logged in, because ' + cause.short + '.'
1425
+ : finding.tenant
1426
+ ? 'lets a signed-in user read rows belonging to a different organization, because ' + cause.short + '.'
1427
+ : 'lets one signed-in user read rows belonging to a different user, because ' + cause.short + '.'),
1428
+ '',
1429
+ ].concat(
1430
+ serverOnlyLines(finding) || [
1431
+ 'Fix it so a person can only read their own rows: compare ' + owner +
1432
+ ' against the id of the signed-in user, and make sure logged-out visitors get nothing.',
1433
+ '',
1434
+ ],
1435
+ ['Then look for the same mistake on every other table and fix those too.'],
1436
+ );
1437
+ // Wrapped here rather than by whoever prints it: this text is pasted into a
1438
+ // chat box as often as it is read in a terminal, and an unwrapped paragraph
1439
+ // is a wall in both.
1440
+ return lines
1441
+ .map((paragraph) => (paragraph ? wrapTo(paragraph, 72) : ['']))
1442
+ .reduce((all, part) => all.concat(part), [])
1443
+ .join('\n');
1444
+ }
1445
+
1446
+ // Found and said, but never claimed as a break: the owner decides. Counted
1447
+ // apart in the report, never towards the exit code or a clean re-check.
1448
+ const TO_CHECK = ['privileged', 'role', 'teamread', 'bucket', 'searchpath', 'nullcheck', 'noinsert'];
1449
+ // Functions, said by their name rather than a table's.
1450
+ const ON_A_FUNCTION = ['privileged', 'searchpath', 'conflict'];
1451
+
1452
+ /** Everything the report needs about one thing the attack found. */
1453
+ function describe(finding) {
1454
+ const contents = readContents(finding.columns);
1455
+ const cause = causeOf(finding);
1456
+ return {
1457
+ severity: severityOf(finding, contents),
1458
+ // Every other finding here was proven by an attack that ran; this is the
1459
+ // one the tool reasons about without executing, so it says so. The report
1460
+ // and the re-check both read this: a "verification required" finding is
1461
+ // never counted towards a clean re-check on its own.
1462
+ status: (TO_CHECK.includes(finding.kind) && !(finding.kind === 'role' && finding.promote)) || publicByDesign(finding)
1463
+ ? 'verification required' : 'confirmed',
1464
+ table: ON_A_FUNCTION.includes(finding.kind) ? finding.fn : finding.table,
1465
+ fn: finding.fn,
1466
+ args: finding.args,
1467
+ writes: finding.writes,
1468
+ kind: finding.kind,
1469
+ // Carried through because a table can have two different columns that each
1470
+ // accept a duplicate, and the re-check tells one finding from another by
1471
+ // what it is about. Without the column they would share an identity and
1472
+ // fixing one would look like fixing both.
1473
+ column: finding.column,
1474
+ parent: finding.parent,
1475
+ parentKey: finding.parentKey,
1476
+ expectation: finding.expectation,
1477
+ headline: headlineFor(finding, contents),
1478
+ body: bodyFor(finding, contents),
1479
+ // For a privileged function there is no "why the door is open" the way a
1480
+ // table has one; the body already carries the whole explanation, so the
1481
+ // cause line would only repeat it. Left empty and skipped by the report.
1482
+ cause: TO_CHECK.includes(finding.kind) || finding.kind === 'conflict' || publicByDesign(finding) ? '' : cause.long,
1483
+ who: finding.who,
1484
+ can: finding.can,
1485
+ // A view, and the table under it, so the re-check and the read-back say
1486
+ // "view" and name the table the same way the report did (DB-09).
1487
+ isView: finding.isView,
1488
+ base: finding.base,
1489
+ via: finding.via,
1490
+ roleColumn: finding.roleColumn,
1491
+ readable: finding.readable,
1492
+ secrets: finding.secrets,
1493
+ bucket: finding.bucket,
1494
+ rules: finding.rules,
1495
+ // An organization's rows read from outside it, and a member taking over
1496
+ // another member's row: kept so the re-check and the read-back word them
1497
+ // the same way the report did.
1498
+ tenant: finding.tenant,
1499
+ asRole: finding.asRole,
1500
+ takeover: finding.takeover,
1501
+ personColumn: finding.personColumn,
1502
+ promote: finding.promote,
1503
+ movein: finding.movein,
1504
+ moved: finding.moved,
1505
+ toRole: finding.toRole,
1506
+ nullSlips: finding.nullSlips,
1507
+ noRule: finding.noRule,
1508
+ writers: finding.writers,
1509
+ // Read from how a function or column is declared (scan.js searchPathsOf,
1510
+ // conflictsIn, emptyChoicesOf); kept so the read-back says it the same way.
1511
+ target: finding.target,
1512
+ on: finding.on,
1513
+ partialWhere: finding.partialWhere,
1514
+ allowed: finding.allowed,
1515
+ anon: finding.anon,
1516
+ proof: finding.kind === 'searchpath'
1517
+ ? 'I did not call "' + finding.fn + '". I read that it is SECURITY DEFINER with no search_path set.'
1518
+ : finding.kind === 'conflict'
1519
+ ? 'I asked Postgres to plan its INSERT INTO ' + finding.target + ' ... ON CONFLICT (' + finding.on +
1520
+ ') on the copy, and it refused. I did not call the function.'
1521
+ : finding.kind === 'nullcheck'
1522
+ ? 'I read that "' + finding.table + '.' + finding.column + '" may be NULL and has no default. I did not insert anything.'
1523
+ : finding.kind === 'noinsert'
1524
+ ? 'I read that every rule for adding rows to "' + finding.table + '" (' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
1525
+ ') has neither USING nor WITH CHECK. I did not insert anything.'
1526
+ : finding.kind === 'recursive'
1527
+ ? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
1528
+ loopError(finding) + '.'
1529
+ : finding.kind === 'privileged'
1530
+ ? 'I did not call "' + finding.fn + '". This is a reach that exists in the grants, not a break I ran.'
1531
+ : finding.kind === 'role' && finding.takeover
1532
+ ? 'As ' + aRole(finding.who) + ' who had just joined, I put my own id on the other person\'s row of "' +
1533
+ finding.via + '", which I could not touch before joining. It was undone.'
1534
+ : finding.kind === 'role' && finding.movein
1535
+ ? 'Signed in, and not a member, I moved my own row of "' + finding.via + '" into the other ' + oneOf(finding.parent) +
1536
+ ' with "' + finding.roleColumn + '" set to "' + finding.toRole + '". It was undone.'
1537
+ : finding.kind === 'role' && finding.promote
1538
+ ? 'As ' + aRole(finding.who) + ' who had just joined, I set "' + finding.roleColumn + '" on my own row of "' +
1539
+ finding.via + '" to "' + finding.toRole + '". It was undone.'
1540
+ : finding.kind === 'crossed' && finding.asRole
1541
+ ? 'As "' + finding.asRole + '" of my own ' + oneOf(finding.parent) + ', I read ' + finding.readable + ' row(s) of "' +
1542
+ finding.table + '" belonging to another; with an ordinary role, none. My role was put back.'
1543
+ : finding.kind === 'role'
1544
+ ? 'As ' + aRole(finding.who) + ' who had just joined, I ' +
1545
+ listOf((finding.can || []).map((what) => {
1546
+ const n = (finding.changed || {})[what] || 1;
1547
+ return ({ add: 'added ', change: 'changed ', delete: 'deleted ' })[what] + n + (n === 1 ? ' row' : ' rows');
1548
+ })) + ' in "' + finding.table + '" that I could not touch before joining. All of it was undone.'
1549
+ : finding.kind === 'teamread'
1550
+ ? 'As ' + aRole(finding.who) + ' who had just joined, I read ' + (finding.readable || 1) +
1551
+ ((finding.readable || 1) === 1 ? ' row' : ' rows') + ' of "' + finding.table + '" that I could not read before joining.'
1552
+ : finding.kind === 'bucket' && finding.noRule
1553
+ ? 'I read that "' + finding.bucket + '" is public and that no rule says who may read it' +
1554
+ ((finding.writers || []).length ? ', while ' + listOf((finding.writers || []).map((r) => '"' + r + '"')) +
1555
+ ' limit uploads and deletes to signed-in members' : '') + '. I did not open any file.'
1556
+ : finding.kind === 'bucket'
1557
+ ? 'I read that "' + finding.bucket + '" is public and that ' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
1558
+ ' says who may read it. I did not open any file.'
1559
+ : finding.kind === 'duplicated'
1560
+ ? 'I created ' + finding.copies + ' rows in "' + finding.table + '" holding the same ' +
1561
+ finding.column + '.'
1562
+ : finding.countOnly
1563
+ ? 'I counted ' + finding.readable + ' ' + (finding.readable === 1 ? 'row' : 'rows') +
1564
+ ' in "' + finding.table + '" that a stranger can read, without pulling any of them out.'
1565
+ : 'I read ' + finding.readable + ' ' + (finding.readable === 1 ? 'row' : 'rows') +
1566
+ ' from "' + finding.table + '" that should not have been readable.',
1567
+ fixPrompt: fixPromptFor(finding),
1568
+ contents: contents,
1569
+ };
1570
+ }
1571
+
1572
+ /** Everything wrong with one table's rules, said once. */
1573
+ function mergeTable(group, one) {
1574
+ const members = group.map(one);
1575
+ const read = group.find((m) => m.kind === 'exposed');
1576
+ const anyoneWrites = group.find((m) => m.kind === 'writable' && m.who === 'anyone');
1577
+ const customersWrite = group.find((m) => m.kind === 'writable' && m.who !== 'anyone');
1578
+ const lead = anyoneWrites || customersWrite || read;
1579
+ const leadItem = members[group.indexOf(lead)];
1580
+ const table = lead.table;
1581
+ const order = ['delete', 'change', 'add'];
1582
+ const does = (w) => order.filter((m) => (w.can || []).includes(m)).map((m) => WRITE_WORDS[m]);
1583
+ let headline;
1584
+ if (read && anyoneWrites) {
1585
+ const verbs = ['read'].concat(order.filter((m) => (anyoneWrites.can || []).includes(m)));
1586
+ headline = 'Anyone can ' + listOf(verbs) + ' rows in your ' + table + ' table.';
1587
+ } else if (read && customersWrite) {
1588
+ headline = 'Anyone can read your ' + table + ' table, and any signed-in customer can ' + listOf(does(customersWrite)) + ' it.';
1589
+ } else {
1590
+ headline = leadItem.headline;
1591
+ }
1592
+ // The customers' part is said in one line when strangers can already do it.
1593
+ const bodies = [];
1594
+ for (let i = 0; i < group.length; i++) {
1595
+ if (group[i] === customersWrite && anyoneWrites) continue;
1596
+ bodies.push(members[i].body);
1597
+ }
1598
+ if (customersWrite && anyoneWrites) bodies.push('Signed-in customers can do the same.');
1599
+ const severity = members.some((m) => m.severity === 'CRITICAL') ? 'CRITICAL' : leadItem.severity;
1600
+ const can = [];
1601
+ for (const m of group) for (const c of m.can || []) if (!can.includes(c)) can.push(c);
1602
+ return Object.assign({}, leadItem, {
1603
+ severity: severity,
1604
+ status: 'confirmed',
1605
+ kind: lead.kind,
1606
+ who: read || anyoneWrites ? 'anyone' : leadItem.who,
1607
+ can: can,
1608
+ headline: headline,
1609
+ body: bodies.join(' '),
1610
+ proof: members.map((m) => m.proof).join(' '),
1611
+ fixPrompt: groupPromptFor(group),
1612
+ members: members,
1613
+ });
1614
+ }
1615
+
1616
+ /** The whole report, in the order a person should read it. */
1617
+ function describeAll(findings) {
1618
+ // A table anyone can read is also, necessarily, a table one customer can read
1619
+ // of another. Reporting both puts the same table on screen twice and turns
1620
+ // two problems into a list of three that reads like a mistake. The public one
1621
+ // is kept, and it carries the rest.
1622
+ const raw = findings || [];
1623
+ const publiclyOpen = new Set(raw.filter((f) => f.kind === 'exposed').map((f) => f.table));
1624
+ // Writes through a view that strangers can already make are said once, by
1625
+ // the strangers' finding, with the customers' part as one line.
1626
+ const strangersThrough = (f) => raw.find((o) => o.kind === 'writable' && o.isView && o.who === 'anyone' && o.table === f.table &&
1627
+ (f.can || []).every((c) => (o.can || []).includes(c)));
1628
+ const kept = raw.filter((f) => !(f.kind === 'crossed' && publiclyOpen.has(f.table)) &&
1629
+ !(f.kind === 'writable' && f.isView && f.who !== 'anyone' && strangersThrough(f)));
1630
+ const one = (f) => {
1631
+ const item = Object.assign(describe(f), {
1632
+ alsoCrossed: f.kind === 'exposed' && raw.some((o) => o.kind === 'crossed' && o.table === f.table),
1633
+ });
1634
+ if (item.alsoCrossed) item.body += ' Your signed-in customers can read each other\'s rows for the same reason.';
1635
+ if (f.kind === 'writable' && f.isView && f.who === 'anyone' &&
1636
+ raw.some((o) => o.kind === 'writable' && o.isView && o.who !== 'anyone' && o.table === f.table)) {
1637
+ item.body += ' Signed-in customers can do the same.';
1638
+ }
1639
+ return item;
1640
+ };
1641
+
1642
+ // One table, one finding. A single rule written FOR ALL USING (true) opens a
1643
+ // table to reading and writing, for strangers and customers alike - four
1644
+ // proofs of one mistake. Listed as four, a real app's report showed six
1645
+ // CRITICAL findings for two rules, and the person reads six problems where
1646
+ // there are two to fix. The proofs are kept as members, so a re-check can
1647
+ // still tell "strangers can no longer write" from "strangers can still read".
1648
+ const grouped = new Map();
1649
+ for (const f of kept) {
1650
+ if ((f.kind !== 'exposed' && f.kind !== 'writable') || f.isView || f.countOnly) continue;
1651
+ if (!grouped.has(f.table)) grouped.set(f.table, []);
1652
+ grouped.get(f.table).push(f);
1653
+ }
1654
+ const described = [];
1655
+ const done = new Set();
1656
+ for (const f of kept) {
1657
+ const group = grouped.get(f.table);
1658
+ if (!group || group.length < 2 || group.indexOf(f) === -1) {
1659
+ described.push(one(f));
1660
+ continue;
1661
+ }
1662
+ if (done.has(f.table)) continue;
1663
+ done.add(f.table);
1664
+ described.push(mergeTable(group, one));
1665
+ }
1666
+ // Worst first, and within that the ones open to the whole internet before the
1667
+ // ones that need an account, and those before the ones that need two requests
1668
+ // to arrive together.
1669
+ // Writes above reads: a table somebody emptied is worse than one they read.
1670
+ // A looping rule sits with the reads: it breaks the app for everyone it
1671
+ // fails for, which matters more than a duplicate or a stray row.
1672
+ const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, conflict: 2.6, duplicated: 3, orphaned: 4, role: 4.5, teamread: 4.6, bucket: 4.7, privileged: 5, searchpath: 5.5, noinsert: 5.7, nullcheck: 6 };
1673
+ const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
1674
+ return described.sort((a, b) => rank(a) - rank(b));
1675
+ }
1676
+
1677
+ /** What is printed when a run finds nothing. Silence would read as a failure. */
1678
+ function allClearLines(attacksRun) {
1679
+ // Nothing attacked is not the same as nothing got through. The callers all
1680
+ // stop before this now, but the sentence is the one thing in the product
1681
+ // that must never be printed by accident, so it guards itself too.
1682
+ if (!attacksRun) {
1683
+ return ['', ' I did not manage to attack anything, so there is nothing to report.', ''];
1684
+ }
1685
+ // "Your data held" used to be the last line here, and it was the wrong
1686
+ // sentence: it says something about the app, and all this program knows is
1687
+ // something about the attacks it happened to run. A person who reads "your
1688
+ // data held" stops looking. A person who reads "these attacks lost" knows
1689
+ // what they have been handed and what they have not.
1690
+ return [
1691
+ '',
1692
+ ' Nothing got through.',
1693
+ '',
1694
+ ' I ran ' + attacksRun + ' ' + (attacksRun === 1 ? 'attack' : 'attacks') + ' against a copy of your app,',
1695
+ ' and every one of them lost.',
1696
+ '',
1697
+ ' That is not the same as "your app is safe". It means these attacks,',
1698
+ ' against this shape of database, this time, did not get in. Anything I',
1699
+ ' did not try is listed at the end.',
1700
+ '',
1701
+ ];
1702
+ }
1703
+
1704
+ module.exports = {
1705
+ publicByDesign: publicByDesign,
1706
+ authTiesOf: authTiesOf,
1707
+ readContents: readContents,
1708
+ severityOf: severityOf,
1709
+ causeOf: causeOf,
1710
+ describe: describe,
1711
+ describeAll: describeAll,
1712
+ fixPromptFor: fixPromptFor,
1713
+ allClearLines: allClearLines,
1714
+ listOf: listOf,
1715
+ };