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.
- package/attack.js +1028 -1025
- package/bin/kryptheon-night.js +736 -725
- package/engine.sql +4657 -4430
- package/finding.js +1715 -1612
- package/intro.js +311 -303
- package/package.json +98 -98
- package/scan.js +1330 -1311
- package/schema.js +1588 -1555
- 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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
*/
|
|
123
|
-
function
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
'
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
if (
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
if (finding.kind === '
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
if (finding.kind === '
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
'
|
|
508
|
-
|
|
509
|
-
'
|
|
510
|
-
'
|
|
511
|
-
'
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
//
|
|
518
|
-
|
|
519
|
-
return (
|
|
520
|
-
'
|
|
521
|
-
|
|
522
|
-
'
|
|
523
|
-
'
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
if (finding.kind === '
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
'
|
|
537
|
-
|
|
538
|
-
)
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
//
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
'
|
|
557
|
-
'
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
'
|
|
561
|
-
'
|
|
562
|
-
);
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
if (finding.kind === '
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
const place = oneOf(finding.parent);
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
'
|
|
573
|
-
'
|
|
574
|
-
'
|
|
575
|
-
(
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
return (
|
|
583
|
-
'
|
|
584
|
-
'
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
'
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
'
|
|
623
|
-
'
|
|
624
|
-
(finding.
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
'
|
|
649
|
-
'
|
|
650
|
-
'
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
'
|
|
660
|
-
'
|
|
661
|
-
|
|
662
|
-
'
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
' I did not call it
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
'
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
if (
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
function
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
const
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
'',
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
'',
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
'',
|
|
998
|
-
'
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
'
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
''
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
'
|
|
1014
|
-
|
|
1015
|
-
'
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
'
|
|
1019
|
-
'
|
|
1020
|
-
|
|
1021
|
-
'
|
|
1022
|
-
''
|
|
1023
|
-
|
|
1024
|
-
'
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
'
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
'
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
''
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
'
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
'',
|
|
1069
|
-
'
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
'
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
'
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
'
|
|
1090
|
-
'
|
|
1091
|
-
'',
|
|
1092
|
-
'
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
'
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
'
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
''
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
'
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
'
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
'
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
'',
|
|
1140
|
-
'
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
'
|
|
1144
|
-
'
|
|
1145
|
-
|
|
1146
|
-
'
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
'the
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
'',
|
|
1158
|
-
'
|
|
1159
|
-
|
|
1160
|
-
'
|
|
1161
|
-
|
|
1162
|
-
'',
|
|
1163
|
-
'
|
|
1164
|
-
'
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
''
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
''
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
'',
|
|
1197
|
-
'
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
'
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
'"
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
'',
|
|
1210
|
-
'
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
'
|
|
1215
|
-
'
|
|
1216
|
-
|
|
1217
|
-
''
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
'
|
|
1276
|
-
'
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
''
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
''
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
'',
|
|
1305
|
-
'
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
'',
|
|
1315
|
-
'
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
''
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
//
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
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
|
+
};
|