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/scan.js
CHANGED
|
@@ -1,1311 +1,1330 @@
|
|
|
1
|
-
// One night's work, end to end.
|
|
2
|
-
//
|
|
3
|
-
// read the app's shape -> rebuild it somewhere safe -> seed two fake
|
|
4
|
-
// people -> attack the copy -> say what got through -> delete the copy
|
|
5
|
-
//
|
|
6
|
-
// The live app is read and never written to. Nothing is copied except the
|
|
7
|
-
// shape: no rows, no customers, no orders. Every row the attack reads is a row
|
|
8
|
-
// this tool put there itself, which is what makes the report safe to send.
|
|
9
|
-
//
|
|
10
|
-
// Run with:
|
|
11
|
-
// KN_DATABASE_URL=postgresql://... node scan.js <schema>
|
|
12
|
-
// KN_DATABASE_URL=postgresql://... node scan.js <schema> --recheck
|
|
13
|
-
|
|
14
|
-
const fs = require('fs');
|
|
15
|
-
const path = require('path');
|
|
16
|
-
const { Client } = require('pg');
|
|
17
|
-
const schema = require('./schema.js');
|
|
18
|
-
const attack = require('./attack.js');
|
|
19
|
-
const finding = require('./finding.js');
|
|
20
|
-
const collision = require('./collision.js');
|
|
21
|
-
const tamper = require('./tamper.js');
|
|
22
|
-
const orphan = require('./orphan.js');
|
|
23
|
-
const recheck = require('./recheck.js');
|
|
24
|
-
const store = require('./store.js');
|
|
25
|
-
|
|
26
|
-
// Where the last run is kept so the next one has something to compare against:
|
|
27
|
-
// the project's store under ~/.kryptheon, not a temp folder, because a
|
|
28
|
-
// re-check a week later has to find it - and not the project, because a saved
|
|
29
|
-
// run lists one app's tables and exactly where it is weak, which is the last
|
|
30
|
-
// thing that should ever be committed. It used to be .kryptheon-last.json in
|
|
31
|
-
// the project; the store moves one it finds there, once, and says so.
|
|
32
|
-
//
|
|
33
|
-
// Throws when the store cannot be made: a run that could not be saved would
|
|
34
|
-
// leave the next re-check nothing to hold anyone to.
|
|
35
|
-
function lastRunFile(say) {
|
|
36
|
-
const opened = store.open(process.cwd());
|
|
37
|
-
const lines = store.migrationLines(opened);
|
|
38
|
-
if (lines.length && say) {
|
|
39
|
-
say('');
|
|
40
|
-
lines.forEach(say);
|
|
41
|
-
}
|
|
42
|
-
return opened.nightLast;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const line = (text) => process.stdout.write(text + '\n');
|
|
46
|
-
|
|
47
|
-
// How long a copy has to be lying around before it is treated as abandoned.
|
|
48
|
-
// Long enough that a scan running in another window is never swept out from
|
|
49
|
-
// under itself; short enough that nobody finds week-old schemas in their
|
|
50
|
-
// database.
|
|
51
|
-
const ABANDONED_AFTER = 6 * 60 * 60 * 1000;
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Drops copies an earlier run left behind.
|
|
55
|
-
*
|
|
56
|
-
* The copy's name carries the moment it was made, so its age can be read
|
|
57
|
-
* without asking Postgres - which does not record when a schema was created.
|
|
58
|
-
* Anything younger than a few hours is left strictly alone: it may well belong
|
|
59
|
-
* to a scan that is running right now.
|
|
60
|
-
*/
|
|
61
|
-
async function sweepOldCopies(client, mine) {
|
|
62
|
-
const dropped = [];
|
|
63
|
-
let rows = [];
|
|
64
|
-
try {
|
|
65
|
-
({ rows } = await client.query(
|
|
66
|
-
// Copies, and the engine schemas the SQL door installs. A copy is
|
|
67
|
-
// kn_<moment>; an engine is kn_engine_<moment>. The engine used to be
|
|
68
|
-
// outside this pattern entirely, so one abandoned by a dropped
|
|
69
|
-
// connection sat in the customer's database for ever - which is the
|
|
70
|
-
// one thing this product promises never to do.
|
|
71
|
-
"SELECT nspname FROM pg_namespace WHERE nspname ~ '^kn_(engine_)?[0-9a-z]+$' AND nspname <> $1",
|
|
72
|
-
[mine],
|
|
73
|
-
));
|
|
74
|
-
} catch (err) {
|
|
75
|
-
return dropped;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
for (const row of rows) {
|
|
79
|
-
// The moment is whatever follows the last underscore, which is the
|
|
80
|
-
// whole name after kn_ for a copy and the tail for an engine.
|
|
81
|
-
const parts = String(row.nspname).split('_');
|
|
82
|
-
const made = parseInt(parts[parts.length - 1], 36);
|
|
83
|
-
if (!Number.isFinite(made) || Date.now() - made < ABANDONED_AFTER) continue;
|
|
84
|
-
try {
|
|
85
|
-
await client.query('DROP SCHEMA IF EXISTS ' + schema.quote(row.nspname) + ' CASCADE');
|
|
86
|
-
dropped.push(row.nspname);
|
|
87
|
-
} catch (err) {
|
|
88
|
-
// Not ours to force. Better to leave it than to fail somebody's scan.
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return dropped;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Every schema in the database, and how many tables are in each.
|
|
96
|
-
*
|
|
97
|
-
* Nothing filtered out. The first version left out anything named like one of
|
|
98
|
-
* our copies, which made the scan refuse to look at a schema whose name merely
|
|
99
|
-
* began the same way - and that took the entire shapes suite down a minute
|
|
100
|
-
* after this function was written. What to hide is a question for the
|
|
101
|
-
* suggestion below, not for whether a schema exists.
|
|
102
|
-
*/
|
|
103
|
-
async function schemasWithTables(client) {
|
|
104
|
-
const { rows } = await client.query(
|
|
105
|
-
`SELECT n.nspname AS schema, count(c.oid) FILTER (WHERE c.relkind = 'r')::int AS tables
|
|
106
|
-
FROM pg_namespace n
|
|
107
|
-
LEFT JOIN pg_class c ON c.relnamespace = n.oid
|
|
108
|
-
WHERE n.nspname NOT LIKE 'pg\\_%'
|
|
109
|
-
AND n.nspname <> 'information_schema'
|
|
110
|
-
GROUP BY 1
|
|
111
|
-
ORDER BY 2 DESC, 1`,
|
|
112
|
-
);
|
|
113
|
-
return rows;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
const
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
say('
|
|
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
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
const
|
|
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
|
-
const
|
|
325
|
-
for (const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
for
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
const
|
|
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
|
-
|
|
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
|
-
const
|
|
439
|
-
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
))
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
* Found on
|
|
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
|
-
const
|
|
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
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
*
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
const
|
|
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
|
-
const
|
|
937
|
-
return
|
|
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
|
-
line('');
|
|
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
|
-
line('');
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
line('')
|
|
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
|
-
|
|
1
|
+
// One night's work, end to end.
|
|
2
|
+
//
|
|
3
|
+
// read the app's shape -> rebuild it somewhere safe -> seed two fake
|
|
4
|
+
// people -> attack the copy -> say what got through -> delete the copy
|
|
5
|
+
//
|
|
6
|
+
// The live app is read and never written to. Nothing is copied except the
|
|
7
|
+
// shape: no rows, no customers, no orders. Every row the attack reads is a row
|
|
8
|
+
// this tool put there itself, which is what makes the report safe to send.
|
|
9
|
+
//
|
|
10
|
+
// Run with:
|
|
11
|
+
// KN_DATABASE_URL=postgresql://... node scan.js <schema>
|
|
12
|
+
// KN_DATABASE_URL=postgresql://... node scan.js <schema> --recheck
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { Client } = require('pg');
|
|
17
|
+
const schema = require('./schema.js');
|
|
18
|
+
const attack = require('./attack.js');
|
|
19
|
+
const finding = require('./finding.js');
|
|
20
|
+
const collision = require('./collision.js');
|
|
21
|
+
const tamper = require('./tamper.js');
|
|
22
|
+
const orphan = require('./orphan.js');
|
|
23
|
+
const recheck = require('./recheck.js');
|
|
24
|
+
const store = require('./store.js');
|
|
25
|
+
|
|
26
|
+
// Where the last run is kept so the next one has something to compare against:
|
|
27
|
+
// the project's store under ~/.kryptheon, not a temp folder, because a
|
|
28
|
+
// re-check a week later has to find it - and not the project, because a saved
|
|
29
|
+
// run lists one app's tables and exactly where it is weak, which is the last
|
|
30
|
+
// thing that should ever be committed. It used to be .kryptheon-last.json in
|
|
31
|
+
// the project; the store moves one it finds there, once, and says so.
|
|
32
|
+
//
|
|
33
|
+
// Throws when the store cannot be made: a run that could not be saved would
|
|
34
|
+
// leave the next re-check nothing to hold anyone to.
|
|
35
|
+
function lastRunFile(say) {
|
|
36
|
+
const opened = store.open(process.cwd());
|
|
37
|
+
const lines = store.migrationLines(opened);
|
|
38
|
+
if (lines.length && say) {
|
|
39
|
+
say('');
|
|
40
|
+
lines.forEach(say);
|
|
41
|
+
}
|
|
42
|
+
return opened.nightLast;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const line = (text) => process.stdout.write(text + '\n');
|
|
46
|
+
|
|
47
|
+
// How long a copy has to be lying around before it is treated as abandoned.
|
|
48
|
+
// Long enough that a scan running in another window is never swept out from
|
|
49
|
+
// under itself; short enough that nobody finds week-old schemas in their
|
|
50
|
+
// database.
|
|
51
|
+
const ABANDONED_AFTER = 6 * 60 * 60 * 1000;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Drops copies an earlier run left behind.
|
|
55
|
+
*
|
|
56
|
+
* The copy's name carries the moment it was made, so its age can be read
|
|
57
|
+
* without asking Postgres - which does not record when a schema was created.
|
|
58
|
+
* Anything younger than a few hours is left strictly alone: it may well belong
|
|
59
|
+
* to a scan that is running right now.
|
|
60
|
+
*/
|
|
61
|
+
async function sweepOldCopies(client, mine) {
|
|
62
|
+
const dropped = [];
|
|
63
|
+
let rows = [];
|
|
64
|
+
try {
|
|
65
|
+
({ rows } = await client.query(
|
|
66
|
+
// Copies, and the engine schemas the SQL door installs. A copy is
|
|
67
|
+
// kn_<moment>; an engine is kn_engine_<moment>. The engine used to be
|
|
68
|
+
// outside this pattern entirely, so one abandoned by a dropped
|
|
69
|
+
// connection sat in the customer's database for ever - which is the
|
|
70
|
+
// one thing this product promises never to do.
|
|
71
|
+
"SELECT nspname FROM pg_namespace WHERE nspname ~ '^kn_(engine_)?[0-9a-z]+$' AND nspname <> $1",
|
|
72
|
+
[mine],
|
|
73
|
+
));
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return dropped;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const row of rows) {
|
|
79
|
+
// The moment is whatever follows the last underscore, which is the
|
|
80
|
+
// whole name after kn_ for a copy and the tail for an engine.
|
|
81
|
+
const parts = String(row.nspname).split('_');
|
|
82
|
+
const made = parseInt(parts[parts.length - 1], 36);
|
|
83
|
+
if (!Number.isFinite(made) || Date.now() - made < ABANDONED_AFTER) continue;
|
|
84
|
+
try {
|
|
85
|
+
await client.query('DROP SCHEMA IF EXISTS ' + schema.quote(row.nspname) + ' CASCADE');
|
|
86
|
+
dropped.push(row.nspname);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
// Not ours to force. Better to leave it than to fail somebody's scan.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return dropped;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Every schema in the database, and how many tables are in each.
|
|
96
|
+
*
|
|
97
|
+
* Nothing filtered out. The first version left out anything named like one of
|
|
98
|
+
* our copies, which made the scan refuse to look at a schema whose name merely
|
|
99
|
+
* began the same way - and that took the entire shapes suite down a minute
|
|
100
|
+
* after this function was written. What to hide is a question for the
|
|
101
|
+
* suggestion below, not for whether a schema exists.
|
|
102
|
+
*/
|
|
103
|
+
async function schemasWithTables(client) {
|
|
104
|
+
const { rows } = await client.query(
|
|
105
|
+
`SELECT n.nspname AS schema, count(c.oid) FILTER (WHERE c.relkind = 'r')::int AS tables
|
|
106
|
+
FROM pg_namespace n
|
|
107
|
+
LEFT JOIN pg_class c ON c.relnamespace = n.oid
|
|
108
|
+
WHERE n.nspname NOT LIKE 'pg\\_%'
|
|
109
|
+
AND n.nspname <> 'information_schema'
|
|
110
|
+
GROUP BY 1
|
|
111
|
+
ORDER BY 2 DESC, 1`,
|
|
112
|
+
);
|
|
113
|
+
return rows;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Where to look when nobody said (blind round 17): "public" when it has
|
|
118
|
+
* tables; otherwise the one schema of the app's that does - not Postgres's
|
|
119
|
+
* own, Supabase's, or this tool's (schema.PLATFORM_SCHEMAS, OWN_SCHEMA, the
|
|
120
|
+
* nightly install). Two or more, or none, and "public" stays: the scan then
|
|
121
|
+
* stops and lists them, because choosing between apps is not a guess to make.
|
|
122
|
+
* Returns { schema, chosen } - chosen when it is not "public".
|
|
123
|
+
*/
|
|
124
|
+
async function pickSchema(client, installed) {
|
|
125
|
+
const own = new RegExp(schema.OWN_SCHEMA);
|
|
126
|
+
const all = await schemasWithTables(client);
|
|
127
|
+
const pub = all.find((entry) => entry.schema === 'public');
|
|
128
|
+
if (pub && pub.tables > 0) return { schema: 'public', chosen: false };
|
|
129
|
+
const apps = all.filter((entry) => entry.tables > 0 && entry.schema !== 'public' && !own.test(entry.schema) &&
|
|
130
|
+
!schema.PLATFORM_SCHEMAS.includes(entry.schema) && entry.schema !== (installed || 'kryptheon'));
|
|
131
|
+
return apps.length === 1 ? { schema: apps[0].schema, chosen: true } : { schema: 'public', chosen: false };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* What to try instead.
|
|
136
|
+
*
|
|
137
|
+
* A person who mistyped a schema name is about to mistype it again. Listing
|
|
138
|
+
* what is actually there costs one query and saves the second attempt - but
|
|
139
|
+
* not the throwaway copies, which are ours and about to be deleted.
|
|
140
|
+
*/
|
|
141
|
+
function suggest(schemas) {
|
|
142
|
+
const worth = schemas.filter((entry) => entry.tables > 0 && !/^kn_[0-9a-z]+$/.test(entry.schema));
|
|
143
|
+
if (!worth.length) return '\n This database has no schema with tables in it.';
|
|
144
|
+
return '\n Schemas in this database with tables in them: ' +
|
|
145
|
+
worth.map((entry) => entry.schema + ' (' + entry.tables + ')').join(', ');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The engine, as seven operations, so the scan does not know which one it has.
|
|
150
|
+
*
|
|
151
|
+
* `scan.js` named `schema.js`, `attack.js`, `tamper.js` and `orphan.js`
|
|
152
|
+
* directly, which meant the SQL engine could only ever be reached by the twin
|
|
153
|
+
* check. Two engines and one of them never on the real path is how the unused
|
|
154
|
+
* one rots however good the comparison is.
|
|
155
|
+
*
|
|
156
|
+
* Deliberately not included: the collision race. It needs two requests in
|
|
157
|
+
* flight at once, which needs two connections, and no credential ever moves
|
|
158
|
+
* into the database - so the SQL engine cannot have it and says so. The scan
|
|
159
|
+
* keeps racing for real down `openSession` whichever engine it is given, and
|
|
160
|
+
* the report says what could not be raced when there is no second connection.
|
|
161
|
+
* That is the one place the two doors are honestly not the same, and it is
|
|
162
|
+
* named rather than papered over.
|
|
163
|
+
*/
|
|
164
|
+
function nodeEngine() {
|
|
165
|
+
return {
|
|
166
|
+
name: 'node',
|
|
167
|
+
readSchema: (client, target) => schema.readSchema(client, target),
|
|
168
|
+
writeSchema: (client, plan, into) => schema.writeSchema(client, plan, into),
|
|
169
|
+
seed: (client, into, tables) => attack.seed(client, into, tables),
|
|
170
|
+
impersonate: (client, into, tables) => attack.impersonate(client, into, tables),
|
|
171
|
+
tamper: (client, into, tables, seeded, policies) =>
|
|
172
|
+
tamper.tamper(client, into, tables, seeded, policies),
|
|
173
|
+
orphan: (client, into, tables, seeded) => orphan.orphan(client, into, tables, seeded),
|
|
174
|
+
teammate: (client, into, tables, seeded, rules) => tamper.teammate(client, into, tables, seeded, rules),
|
|
175
|
+
conflicts: (client, into, plan, indexes) => conflictsIn(client, into, plan, indexes),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Runs the whole thing and hands back what got through.
|
|
181
|
+
*
|
|
182
|
+
* The copy is dropped in a `finally`, so a crash halfway does not leave a
|
|
183
|
+
* database behind with somebody's schema in it.
|
|
184
|
+
*/
|
|
185
|
+
async function scan(client, sourceSchema, options) {
|
|
186
|
+
const opts = options || {};
|
|
187
|
+
const copyName = 'kn_' + Date.now().toString(36);
|
|
188
|
+
const say = opts.quiet ? () => {} : line;
|
|
189
|
+
// Whichever engine the caller handed over, or the one built in.
|
|
190
|
+
const engine = opts.engine || nodeEngine();
|
|
191
|
+
|
|
192
|
+
// Anything an earlier run could not clean up after itself. The copy is
|
|
193
|
+
// dropped in a `finally`, but a `finally` needs a connection: when the link
|
|
194
|
+
// dies mid-scan the process goes with it and the copy is simply left in the
|
|
195
|
+
// customer's database. Found by exactly that happening on a real schema.
|
|
196
|
+
const swept = await sweepOldCopies(client, copyName);
|
|
197
|
+
if (swept.length) say(' Cleared ' + swept.length + ' copy left by an earlier run.');
|
|
198
|
+
|
|
199
|
+
// Is there anything here at all?
|
|
200
|
+
//
|
|
201
|
+
// Typed `pubic` instead of `public`, this used to read a schema that does
|
|
202
|
+
// not exist, find no tables, attack none of them, and print "Nothing got
|
|
203
|
+
// through. Your data held." A typo produced the one sentence the entire
|
|
204
|
+
// product is sold on, and exited 0 so a script would call it a pass.
|
|
205
|
+
const elsewhere = await schemasWithTables(client);
|
|
206
|
+
const here = elsewhere.find((entry) => entry.schema === sourceSchema);
|
|
207
|
+
if (!here) {
|
|
208
|
+
return {
|
|
209
|
+
stopped: 'There is no schema called "' + sourceSchema + '" in this database.' +
|
|
210
|
+
suggest(elsewhere),
|
|
211
|
+
findings: [],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
say(' Reading the shape of ' + sourceSchema + ' ...');
|
|
216
|
+
const plan = await engine.readSchema(client, sourceSchema);
|
|
217
|
+
|
|
218
|
+
if (!plan.tables.length) {
|
|
219
|
+
// Nothing wrong with the database, but nothing was tested either, and
|
|
220
|
+
// those are not the same answer.
|
|
221
|
+
return {
|
|
222
|
+
stopped: 'The schema "' + sourceSchema + '" has no tables in it, so there was nothing to attack.' +
|
|
223
|
+
suggest(elsewhere.filter((entry) => entry.schema !== sourceSchema)),
|
|
224
|
+
findings: [],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (plan.unsupported.length) {
|
|
229
|
+
// Attacking a copy that is missing pieces would produce verdicts about a
|
|
230
|
+
// database nobody runs, so this stops rather than guesses.
|
|
231
|
+
return {
|
|
232
|
+
stopped: 'Parts of this app could not be copied faithfully:\n ' + plan.unsupported.join('\n '),
|
|
233
|
+
findings: [],
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
say(' ' + plan.tables.length + ' tables, ' + plan.policies.length + ' rules. Building a copy ...');
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
await engine.writeSchema(client, plan, copyName);
|
|
241
|
+
const copyPlan = await engine.readSchema(client, copyName);
|
|
242
|
+
|
|
243
|
+
// The copy has to be the app, or nothing that follows means anything.
|
|
244
|
+
const differences = schema.diffSchemas(plan, copyPlan);
|
|
245
|
+
if (differences.length) {
|
|
246
|
+
return {
|
|
247
|
+
stopped: 'The copy did not come out identical, so nothing was attacked:\n ' + differences.join('\n '),
|
|
248
|
+
findings: [],
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Upserts Postgres would refuse, asked of the empty copy before anything
|
|
253
|
+
// is put in it - planned, never run (conflictsIn).
|
|
254
|
+
const refused = engine.conflicts ? await engine.conflicts(client, copyName, plan, copyPlan.indexes) : [];
|
|
255
|
+
|
|
256
|
+
say(' Copy matches. Seeding two people and attacking it ...');
|
|
257
|
+
// The stand-ins built for tables outside the schema belong to this tool,
|
|
258
|
+
// not to the customer. Attacking them would produce findings about a table
|
|
259
|
+
// that does not exist in their app.
|
|
260
|
+
// Named one by one from what was actually built, not matched on a prefix.
|
|
261
|
+
// A customer table whose name merely looked like ours used to be dropped
|
|
262
|
+
// from every attack, and a table nobody attacks has nothing to report.
|
|
263
|
+
const standIns = new Set((plan.external || []).map((entry) => entry.stub));
|
|
264
|
+
const theirs = copyPlan.tables.filter((table) => !standIns.has(table.name));
|
|
265
|
+
const sown = await engine.seed(client, copyName, theirs);
|
|
266
|
+
|
|
267
|
+
// Views are read but never seeded: they have no rows of their own, they
|
|
268
|
+
// show the rows of the tables underneath. That is exactly why they matter
|
|
269
|
+
// - a view runs with its creator's rights unless it says otherwise, so one
|
|
270
|
+
// over a protected table hands out every row in it while the policy sits
|
|
271
|
+
// there intact and the dashboard stays green.
|
|
272
|
+
const views = (copyPlan.views || []).map((view) => ({
|
|
273
|
+
name: view.name,
|
|
274
|
+
columns: view.columns || [],
|
|
275
|
+
constraints: [],
|
|
276
|
+
rlsEnabled: false,
|
|
277
|
+
isView: true,
|
|
278
|
+
}));
|
|
279
|
+
const impersonation = await engine.impersonate(client, copyName, theirs.concat(views));
|
|
280
|
+
|
|
281
|
+
// Every attack genuinely run, named the way a finding is named. The
|
|
282
|
+
// re-check needs this: a finding that disappears because its attack never
|
|
283
|
+
// ran this time is not a finding that was fixed, and without this list the
|
|
284
|
+
// two are indistinguishable.
|
|
285
|
+
//
|
|
286
|
+
// Two conditions, both required. The read has to have produced a verdict,
|
|
287
|
+
// AND the table has to have had a row in it - because a table nothing
|
|
288
|
+
// could be seeded into returns nothing to a stranger for a reason that has
|
|
289
|
+
// nothing to do with being secure.
|
|
290
|
+
const sownTables = new Set(sown.seeded.map((entry) => entry.table));
|
|
291
|
+
// A view counts as tested once anything underneath it has rows, since that
|
|
292
|
+
// is what it has to show.
|
|
293
|
+
const viewNames = new Set(views.map((view) => view.name));
|
|
294
|
+
const attempted = impersonation.completed.filter((key) => {
|
|
295
|
+
const name = key.split(':')[1];
|
|
296
|
+
// Whether a rule loops needs no rows: Postgres finds the loop while
|
|
297
|
+
// planning the read, before it looks at a single row. A table nothing
|
|
298
|
+
// could be seeded into has still been checked for that.
|
|
299
|
+
if (key.startsWith('recursive:')) return true;
|
|
300
|
+
return sownTables.has(name) || (viewNames.has(name) && sown.seeded.length > 0);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Surfaced, not swallowed. A table with no row in it reads as a safe
|
|
304
|
+
// table, so the one thing that must never happen is reporting an app as
|
|
305
|
+
// clear when part of it was never actually tried.
|
|
306
|
+
const notChecked = sown.skipped.slice().concat(serverIdentityOf(plan));
|
|
307
|
+
|
|
308
|
+
// A read that errored for any reason other than the table being closed to
|
|
309
|
+
// that role decided nothing at all, and zero rows back from a failed query
|
|
310
|
+
// looks exactly like zero rows back from a table that held.
|
|
311
|
+
for (const stuck of impersonation.blocked) {
|
|
312
|
+
if (!sownTables.has(stuck.table) && !viewNames.has(stuck.table)) continue;
|
|
313
|
+
notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Can a stranger change any of it? Every write here is rolled back, so the
|
|
317
|
+
// copy comes out of this exactly as it went in and anything running after
|
|
318
|
+
// reads the same database the earlier attacks did.
|
|
319
|
+
say(' Trying to change data that is not ours ...');
|
|
320
|
+
// The copy's own policies, not the app's - they are the same rules, and
|
|
321
|
+
// the copy is what was actually attacked. The report needs them to say
|
|
322
|
+
// which rule let a write through rather than assuming one.
|
|
323
|
+
const writes = await engine.tamper(client, copyName, theirs, sown.seeded, copyPlan.policies);
|
|
324
|
+
for (const key of writes.completed) attempted.push(key);
|
|
325
|
+
for (const stuck of writes.blocked) {
|
|
326
|
+
notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// What the least trusted member of a team can change there. Rolled back
|
|
330
|
+
// like the writes above, and the joining with them.
|
|
331
|
+
// With the copy's rules and helpers, so a role a rule lists as allowed to
|
|
332
|
+
// write is not reported (tamper.allowedByRules).
|
|
333
|
+
const ranked = await engine.teammate(client, copyName, theirs, sown.seeded,
|
|
334
|
+
{ policies: copyPlan.policies, functions: copyPlan.functions });
|
|
335
|
+
for (const key of ranked.completed) attempted.push(key);
|
|
336
|
+
for (const stuck of ranked.blocked) {
|
|
337
|
+
notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Can a half-finished write survive? Rolled back like the writes above.
|
|
341
|
+
say(' Looking for rows that could point at nothing ...');
|
|
342
|
+
const stranded = await engine.orphan(client, copyName, theirs, sown.seeded);
|
|
343
|
+
for (const key of stranded.completed) attempted.push(key);
|
|
344
|
+
for (const missed of stranded.notTried) {
|
|
345
|
+
notChecked.push({
|
|
346
|
+
table: missed.table + '.' + missed.column,
|
|
347
|
+
key: 'orphaned:' + missed.table + ':' + missed.column,
|
|
348
|
+
why: missed.why,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// A rule that looks itself up. The attacks it stopped stay in the list of
|
|
353
|
+
// what was not tested - they did not run - but the loop is also a finding
|
|
354
|
+
// of its own: every signed-in request that touches the table fails, so the
|
|
355
|
+
// app is broken for the people using it. One finding per table at fault,
|
|
356
|
+
// named from Postgres's own message, however many reads ran into it.
|
|
357
|
+
const loops = new Map();
|
|
358
|
+
// A loop that named no table ("stack depth limit exceeded") is put on the
|
|
359
|
+
// tables whose own rule calls a helper that reads that same table; if
|
|
360
|
+
// none can be found, on the table that was being read.
|
|
361
|
+
const culprits = loopingTables(copyPlan);
|
|
362
|
+
const loopedInto = (relation, table, who) => {
|
|
363
|
+
const deep = !relation;
|
|
364
|
+
for (const at of relation ? [relation] : culprits.length ? culprits : [table]) {
|
|
365
|
+
if (!loops.has(at)) loops.set(at, { kind: 'recursive', table: at, reads: [], callers: [], columns: [], deep: false });
|
|
366
|
+
const entry = loops.get(at);
|
|
367
|
+
if (deep) entry.deep = true;
|
|
368
|
+
if (!entry.reads.includes(table)) entry.reads.push(table);
|
|
369
|
+
if (!entry.callers.includes(who)) entry.callers.push(who);
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
for (const hit of impersonation.looped || []) loopedInto(hit.relation, hit.table, hit.who);
|
|
373
|
+
for (const stuck of writes.blocked) {
|
|
374
|
+
const relation = attack.recursionIn(stuck.why);
|
|
375
|
+
if (relation || attack.tooDeep(stuck.why)) loopedInto(relation || null, stuck.table, /^as anyone:/.test(stuck.why) ? 'anyone' : 'signed-in');
|
|
376
|
+
}
|
|
377
|
+
const recursive = Array.from(loops.values());
|
|
378
|
+
|
|
379
|
+
let collisions = { findings: [], notTried: [], raced: [] };
|
|
380
|
+
if (opts.openSession) {
|
|
381
|
+
say(' Racing two requests against each other ...');
|
|
382
|
+
const one = await opts.openSession();
|
|
383
|
+
const two = await opts.openSession();
|
|
384
|
+
try {
|
|
385
|
+
collisions = await collision.collide(client, one, two, copyName, theirs, copyPlan.indexes);
|
|
386
|
+
} finally {
|
|
387
|
+
await one.end().catch(() => {});
|
|
388
|
+
await two.end().catch(() => {});
|
|
389
|
+
}
|
|
390
|
+
for (const raced of collisions.raced) {
|
|
391
|
+
attempted.push('duplicated:' + raced.table + ':' + raced.column);
|
|
392
|
+
}
|
|
393
|
+
for (const missed of collisions.notTried) {
|
|
394
|
+
notChecked.push({
|
|
395
|
+
table: missed.table + '.' + missed.column,
|
|
396
|
+
key: 'duplicated:' + missed.table + ':' + missed.column,
|
|
397
|
+
why: missed.why,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
} else {
|
|
401
|
+
// Two requests at once needs two connections. Without them the attack
|
|
402
|
+
// cannot happen at all, and a report that quietly omits it reads exactly
|
|
403
|
+
// like a report that ran it and found nothing.
|
|
404
|
+
for (const target of collision.candidates(theirs, copyPlan.indexes).filter((t) => !t.covered)) {
|
|
405
|
+
notChecked.push({
|
|
406
|
+
table: target.table + '.' + target.column,
|
|
407
|
+
key: 'duplicated:' + target.table + ':' + target.column,
|
|
408
|
+
why: 'racing two requests needs a second connection, and none was available',
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Where people actually sign in decides how bad a duplicate email is. On
|
|
414
|
+
// Supabase that is auth.users, which keeps emails unique; a public table's
|
|
415
|
+
// email column is then a copy. Only read, never written.
|
|
416
|
+
const loginElsewhere = (await client.query("select to_regclass('auth.users') is not null as found")).rows[0].found;
|
|
417
|
+
const raced = collisions.findings.map((f) =>
|
|
418
|
+
f.expectation === 'identity' ? Object.assign({}, f, { loginElsewhere: loginElsewhere }) : f,
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
// Functions an anonymous visitor can call that run past the rules. Read
|
|
422
|
+
// from the shape, not attacked: nothing is executed, because whether such
|
|
423
|
+
// a function is meant to be public is a question only the owner answers.
|
|
424
|
+
//
|
|
425
|
+
// Every definer function read this run counts as examined, whether or not
|
|
426
|
+
// anon can call it. That is what lets the re-check call a revoked grant
|
|
427
|
+
// fixed: the function was looked at again and the reach was gone. The first
|
|
428
|
+
// version recorded only the flagged ones, so a revoke made the function
|
|
429
|
+
// vanish from both lists and the fix came back as "could not confirm".
|
|
430
|
+
for (const fn of plan.anonFunctions || []) attempted.push('privileged:' + fn.name);
|
|
431
|
+
const privileged = privilegedOf(plan);
|
|
432
|
+
|
|
433
|
+
// How the app's functions and columns are declared: an open search_path,
|
|
434
|
+
// a list a CHECK keeps that still takes an empty value. Read from the
|
|
435
|
+
// shape. Every function and column looked at counts as examined, so a fix
|
|
436
|
+
// comes back from a re-check as fixed.
|
|
437
|
+
for (const fn of plan.anonFunctions || []) attempted.push('searchpath:' + fn.name);
|
|
438
|
+
for (const fn of plan.functions || []) attempted.push('conflict:' + fn.name);
|
|
439
|
+
for (const x of oneOfColumns(plan)) attempted.push('nullcheck:' + x.table + ':' + x.column.name);
|
|
440
|
+
// Only a table that has a rule for adding rows was looked at for this.
|
|
441
|
+
for (const name of new Set((plan.policies || []).filter((p) => /^(INSERT|ALL)$/i.test(String(p.cmd || 'ALL'))).map((p) => p.table_name))) {
|
|
442
|
+
attempted.push('noinsert:' + name);
|
|
443
|
+
}
|
|
444
|
+
const declared = searchPathsOf(plan).concat(refused, emptyChoicesOf(plan), noInsertsOf(plan));
|
|
445
|
+
|
|
446
|
+
// Storage buckets switched to public that also have a rule saying who may
|
|
447
|
+
// read them. Read from the bucket settings and the rules, never a file.
|
|
448
|
+
// Every bucket read counts as examined, so one made private comes back
|
|
449
|
+
// from a re-check as fixed.
|
|
450
|
+
for (const bucket of plan.buckets || []) attempted.push('bucket:' + bucket.name);
|
|
451
|
+
const buckets = bucketsOf(plan);
|
|
452
|
+
|
|
453
|
+
// Ties read from the original schema: the copy points auth.users keys at
|
|
454
|
+
// a stand-in, and would call every table untied. Owners read from the
|
|
455
|
+
// copy, the way seeding did.
|
|
456
|
+
const tagged = taggerFor(plan, theirs);
|
|
457
|
+
|
|
458
|
+
// Where this run was, so a re-check can tell a database it saw before from
|
|
459
|
+
// another one entirely (recheck.elsewhereOf). The name and address only.
|
|
460
|
+
const at = (await client.query(
|
|
461
|
+
'SELECT current_database() AS database, host(inet_server_addr()) AS host, inet_server_port() AS port',
|
|
462
|
+
)).rows[0];
|
|
463
|
+
|
|
464
|
+
const everything = impersonation.findings.concat(writes.findings, ranked.findings, stranded.findings, raced,
|
|
465
|
+
privileged, declared, buckets, recursive);
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
stopped: null,
|
|
469
|
+
// The app's functions read and never run - named under "What I did not test".
|
|
470
|
+
notCalled: notCalledOf(plan, everything),
|
|
471
|
+
where: {
|
|
472
|
+
database: at.database,
|
|
473
|
+
host: at.host || null,
|
|
474
|
+
port: at.port || null,
|
|
475
|
+
schema: sourceSchema,
|
|
476
|
+
tables: plan.tables.map((table) => table.name).sort(),
|
|
477
|
+
},
|
|
478
|
+
attacksRun: attempted.length,
|
|
479
|
+
notChecked: notChecked,
|
|
480
|
+
attempted: attempted,
|
|
481
|
+
findings: finding.describeAll(everything.map(tagged)),
|
|
482
|
+
};
|
|
483
|
+
} finally {
|
|
484
|
+
try {
|
|
485
|
+
await client.query('DROP SCHEMA IF EXISTS ' + schema.quote(copyName) + ' CASCADE');
|
|
486
|
+
say(' Copy deleted.');
|
|
487
|
+
} catch (err) {
|
|
488
|
+
line(' WARNING: the copy ' + copyName + ' could not be deleted: ' + err.message);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* The exit code of a first run: 2 could not run, 1 something got through, 0
|
|
495
|
+
* every attack lost. Only confirmed findings count as "got through" - a
|
|
496
|
+
* function flagged for verification was never executed, and a script reading
|
|
497
|
+
* 1 would act on a break nobody demonstrated.
|
|
498
|
+
*/
|
|
499
|
+
function exitCodeFor(result) {
|
|
500
|
+
if (result.stopped) return 2;
|
|
501
|
+
return (result.findings || []).some((f) => f.status !== 'verification required') ? 1 : 0;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* The tables whose own rule reaches back into the same table through the
|
|
506
|
+
* helpers it calls - directly or through helpers those call. Such a rule has
|
|
507
|
+
* to be evaluated to be evaluated, which is how "stack depth limit exceeded"
|
|
508
|
+
* happens; the message itself does not say which table.
|
|
509
|
+
*/
|
|
510
|
+
function loopingTables(plan) {
|
|
511
|
+
const escape = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
512
|
+
const calls = (text, name) => new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?\\s*\\(').test(String(text || ''));
|
|
513
|
+
const mentions = (text, name) => new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?([^A-Za-z0-9_$]|$)').test(String(text || ''));
|
|
514
|
+
const fns = (plan && plan.functions) || [];
|
|
515
|
+
const reached = (text) => {
|
|
516
|
+
const found = new Set(fns.filter((f) => calls(text, f.name)).map((f) => f.name));
|
|
517
|
+
let grew = true;
|
|
518
|
+
while (grew) {
|
|
519
|
+
grew = false;
|
|
520
|
+
for (const f of fns) {
|
|
521
|
+
if (found.has(f.name)) continue;
|
|
522
|
+
if (fns.some((g) => found.has(g.name) && calls(g.src, f.name))) { found.add(f.name); grew = true; }
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return fns.filter((f) => found.has(f.name));
|
|
526
|
+
};
|
|
527
|
+
return ((plan && plan.tables) || []).map((t) => t.name).filter((name) => {
|
|
528
|
+
const rules = ((plan && plan.policies) || []).filter((p) => p.table_name === name)
|
|
529
|
+
.map((p) => String(p.qual || '') + ' ' + String(p.with_check || '')).join(' ');
|
|
530
|
+
return rules.trim() && reached(rules).some((f) => mentions(f.src, name));
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Functions an anonymous visitor can call that run past the rules. Read from
|
|
536
|
+
* the shape, not attacked. Quiet: a yes/no about the caller, and a function
|
|
537
|
+
* that turns the caller away before it does anything (schema.guardedAtTheDoor).
|
|
538
|
+
*/
|
|
539
|
+
function privilegedOf(plan) {
|
|
540
|
+
return ((plan && plan.anonFunctions) || []).filter((fn) => fn.callable && !fn.aboutCaller && !fn.guarded).map((fn) => ({
|
|
541
|
+
kind: 'privileged',
|
|
542
|
+
fn: fn.name,
|
|
543
|
+
table: fn.name,
|
|
544
|
+
args: fn.args,
|
|
545
|
+
writes: fn.writes,
|
|
546
|
+
hasFixedSearchPath: fn.hasFixedSearchPath,
|
|
547
|
+
// Its only check at the door is one a caller with no role slips past.
|
|
548
|
+
nullSlips: fn.nullSlips === true,
|
|
549
|
+
columns: [],
|
|
550
|
+
}));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Definer functions the app can call that leave their search_path open.
|
|
555
|
+
*
|
|
556
|
+
* Found on a blind test (Northstar): record_scan was SECURITY DEFINER,
|
|
557
|
+
* callable by every signed-in user, guarded at the door - and it said
|
|
558
|
+
* `insert into scans` with no schema and no search_path set. A name without a
|
|
559
|
+
* schema is looked up along whatever path the session has, so anybody able to
|
|
560
|
+
* put a table or function earlier on that path makes the function use theirs,
|
|
561
|
+
* with the owner's rights. Supabase's own advisor flags the same thing.
|
|
562
|
+
*
|
|
563
|
+
* Read, never run. A function already reported as privileged says this as
|
|
564
|
+
* part of that finding and is not reported twice.
|
|
565
|
+
*/
|
|
566
|
+
function searchPathsOf(plan) {
|
|
567
|
+
const reported = new Set(privilegedOf(plan).map((f) => f.fn));
|
|
568
|
+
return ((plan && plan.anonFunctions) || [])
|
|
569
|
+
.filter((fn) => (fn.callable || fn.callableSignedIn) && !fn.hasFixedSearchPath && !reported.has(fn.name))
|
|
570
|
+
.map((fn) => ({
|
|
571
|
+
kind: 'searchpath',
|
|
572
|
+
fn: fn.name,
|
|
573
|
+
table: fn.name,
|
|
574
|
+
args: fn.args,
|
|
575
|
+
writes: fn.writes,
|
|
576
|
+
anon: fn.callable === true,
|
|
577
|
+
columns: [],
|
|
578
|
+
}));
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// A CHECK that keeps one column to a fixed list, as Postgres writes it back:
|
|
582
|
+
// CHECK ((severity = ANY (ARRAY['critical'::text, 'high'::text])))
|
|
583
|
+
// CHECK (((status)::text = ANY ((ARRAY['a'::character varying])::text[])))
|
|
584
|
+
// Mirrored in engine.sql empty_choices.
|
|
585
|
+
const ONE_OF = /^CHECK \(+"?([A-Za-z_][\w$]*)"?\)?(?:::[\w ]+)? = ANY \(/;
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Columns kept to a fixed list by a CHECK that still take an empty value.
|
|
589
|
+
*
|
|
590
|
+
* Found on a blind test (Northstar): findings.severity had
|
|
591
|
+
* CHECK (severity IN ('critical', 'high', 'medium', 'low')) and no NOT NULL.
|
|
592
|
+
* A CHECK refuses a row only when it can show the value wrong, and NULL is
|
|
593
|
+
* never shown wrong - so a finding with no severity at all was accepted, and
|
|
594
|
+
* the app's report showed a row with no label. Only said when nothing else
|
|
595
|
+
* fills the column: no default, not generated, and a CHECK that does not
|
|
596
|
+
* mention NULL itself (a CHECK that does has decided the question).
|
|
597
|
+
*/
|
|
598
|
+
/**
|
|
599
|
+
* Tables nobody can add a row to: every rule for adding rows is an INSERT
|
|
600
|
+
* rule written with neither USING nor WITH CHECK, which Postgres reads as
|
|
601
|
+
* "no" - measured on PostgreSQL 18, a bare `create policy p on t for insert
|
|
602
|
+
* to authenticated;` refuses every insert. A rule for ALL, or one with a
|
|
603
|
+
* check, is a way in, and the table is left alone. Mirrors no_insert_findings
|
|
604
|
+
* in engine.sql.
|
|
605
|
+
*
|
|
606
|
+
* Found on blind round 15 (Northstar Ops on Supabase): tickets_insert was
|
|
607
|
+
* written that way, so create_ticket failed for every user - the app's main
|
|
608
|
+
* feature - and nothing in the report said so.
|
|
609
|
+
*/
|
|
610
|
+
function noInsertsOf(plan) {
|
|
611
|
+
const byTable = new Map();
|
|
612
|
+
for (const p of (plan && plan.policies) || []) {
|
|
613
|
+
if (String(p.permissive || 'PERMISSIVE').toUpperCase() !== 'PERMISSIVE') continue;
|
|
614
|
+
const cmd = String(p.cmd || 'ALL').toUpperCase();
|
|
615
|
+
if (cmd !== 'INSERT' && cmd !== 'ALL') continue;
|
|
616
|
+
if (!byTable.has(p.table_name)) byTable.set(p.table_name, { bare: [], open: false });
|
|
617
|
+
const entry = byTable.get(p.table_name);
|
|
618
|
+
if (cmd === 'INSERT' && p.qual == null && p.with_check == null) entry.bare.push(p.name);
|
|
619
|
+
else entry.open = true;
|
|
620
|
+
}
|
|
621
|
+
return Array.from(byTable.keys()).sort()
|
|
622
|
+
.filter((table) => byTable.get(table).bare.length && !byTable.get(table).open)
|
|
623
|
+
.map((table) => ({ kind: 'noinsert', table: table, rules: byTable.get(table).bare.slice().sort(), columns: [] }));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function emptyChoicesOf(plan) {
|
|
627
|
+
return oneOfColumns(plan)
|
|
628
|
+
.filter((x) => !x.column.not_null && x.column.default_expr == null && !x.column.generated)
|
|
629
|
+
.map((x) => ({
|
|
630
|
+
kind: 'nullcheck',
|
|
631
|
+
table: x.table,
|
|
632
|
+
column: x.column.name,
|
|
633
|
+
allowed: Array.from(x.definition.matchAll(/'((?:[^']|'')*)'/g)).map((m) => m[1].split("''").join("'")),
|
|
634
|
+
constraint: x.constraint,
|
|
635
|
+
columns: [],
|
|
636
|
+
}));
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Every column a CHECK keeps to a fixed list - each one looked at, empty or not. */
|
|
640
|
+
function oneOfColumns(plan) {
|
|
641
|
+
const found = [];
|
|
642
|
+
for (const table of (plan && plan.tables) || []) {
|
|
643
|
+
for (const rule of table.constraints || []) {
|
|
644
|
+
if (rule.kind !== 'c') continue;
|
|
645
|
+
const def = String(rule.definition || '');
|
|
646
|
+
if (/\b(or|null|and)\b/i.test(def)) continue;
|
|
647
|
+
const m = ONE_OF.exec(def);
|
|
648
|
+
if (!m) continue;
|
|
649
|
+
const column = (table.columns || []).find((c) => c.name === m[1]);
|
|
650
|
+
if (column) found.push({ table: table.name, column: column, constraint: rule.name, definition: def });
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return found;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Every INSERT ... ON CONFLICT (...) in the app's functions, with the table it
|
|
658
|
+
* writes and the conflict target, read from the function's text. Mirrored in
|
|
659
|
+
* engine.sql conflict_targets, step for step: statements split on ";", the
|
|
660
|
+
* table after INSERT INTO, the columns inside ON CONFLICT ( ), and a WHERE
|
|
661
|
+
* right after them up to DO.
|
|
662
|
+
*/
|
|
663
|
+
function conflictTargetsOf(plan) {
|
|
664
|
+
const tables = new Set(((plan && plan.tables) || []).map((t) => t.name));
|
|
665
|
+
const unquote = (part) => (part.startsWith('"') ? part.slice(1, -1) : part.toLowerCase());
|
|
666
|
+
const seen = new Set();
|
|
667
|
+
const out = [];
|
|
668
|
+
for (const fn of (plan && plan.functions) || []) {
|
|
669
|
+
const body = String(fn.src || '').replace(/--[^\n]*/g, ' ');
|
|
670
|
+
for (const statement of body.split(';')) {
|
|
671
|
+
const into = /(?:^|[^\w$])insert\s+into\s+((?:"[^"]+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"[^"]+"|[A-Za-z_][\w$]*))?)/i.exec(statement);
|
|
672
|
+
if (!into) continue;
|
|
673
|
+
const on = /(?:^|[^\w$])on\s+conflict\s*\(([^)]*)\)([\s\S]*)$/i.exec(statement);
|
|
674
|
+
if (!on || !on[1].trim()) continue;
|
|
675
|
+
const parts = into[1].match(/"[^"]+"|[A-Za-z_][\w$]*/g);
|
|
676
|
+
if (parts.length === 2 && unquote(parts[0]) !== plan.schema) continue;
|
|
677
|
+
const target = unquote(parts[parts.length - 1]);
|
|
678
|
+
if (!tables.has(target)) continue;
|
|
679
|
+
const where = /^\s*where\b/i.test(on[2]) ? on[2].split(/\bdo\b/i)[0].trim() : '';
|
|
680
|
+
const key = [fn.name, target, on[1].trim(), where].join('|');
|
|
681
|
+
if (seen.has(key)) continue;
|
|
682
|
+
seen.add(key);
|
|
683
|
+
out.push({ fn: fn.name, args: fn.args || '', target: target, on: on[1].trim(), where: where });
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return out;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* The WHERE of a partial unique index on exactly these columns, if there is
|
|
691
|
+
* one: what the ON CONFLICT has to repeat to be matched. Mirrored in
|
|
692
|
+
* engine.sql partial_where_for.
|
|
693
|
+
*/
|
|
694
|
+
function partialWhereFor(indexes, table, on) {
|
|
695
|
+
const norm = (text) => String(text).replace(/["\s]/g, '').toLowerCase();
|
|
696
|
+
for (const index of indexes || []) {
|
|
697
|
+
if (index.table_name !== table) continue;
|
|
698
|
+
const m = /USING \w+ \(([^)]*)\)(?: WHERE (.*))?$/.exec(String(index.definition || ''));
|
|
699
|
+
if (m && m[2] && norm(m[1]) === norm(on)) return m[2];
|
|
700
|
+
}
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* The upserts Postgres refuses to run, asked on the copy.
|
|
706
|
+
*
|
|
707
|
+
* Found on a blind test (Northstar): upsert_project said
|
|
708
|
+
* ON CONFLICT (organization_id, external_ref), and the only unique index on
|
|
709
|
+
* those columns was partial (WHERE external_ref IS NOT NULL). Postgres can
|
|
710
|
+
* only use a partial index when the ON CONFLICT repeats its WHERE, so it
|
|
711
|
+
* refused the statement and the function failed on every call.
|
|
712
|
+
*
|
|
713
|
+
* Nothing of the app's is run. Each statement is put to the copy as
|
|
714
|
+
* EXPLAIN INSERT ... DEFAULT VALUES ON CONFLICT (...) DO NOTHING - planned,
|
|
715
|
+
* never executed - and only Postgres's own "no unique or exclusion constraint
|
|
716
|
+
* matching" (42P10) counts. Any other error says nothing either way.
|
|
717
|
+
*/
|
|
718
|
+
async function conflictsIn(client, into, plan, indexes) {
|
|
719
|
+
const found = [];
|
|
720
|
+
for (const t of conflictTargetsOf(plan)) {
|
|
721
|
+
let refused = false;
|
|
722
|
+
await client.query('BEGIN');
|
|
723
|
+
try {
|
|
724
|
+
await client.query('EXPLAIN INSERT INTO ' + schema.quote(into) + '.' + schema.quote(t.target) +
|
|
725
|
+
' DEFAULT VALUES ON CONFLICT (' + t.on + ')' + (t.where ? ' ' + t.where : '') + ' DO NOTHING');
|
|
726
|
+
} catch (err) {
|
|
727
|
+
refused = err.code === '42P10';
|
|
728
|
+
} finally {
|
|
729
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
730
|
+
}
|
|
731
|
+
if (!refused) continue;
|
|
732
|
+
found.push({
|
|
733
|
+
kind: 'conflict',
|
|
734
|
+
fn: t.fn,
|
|
735
|
+
table: t.fn,
|
|
736
|
+
args: t.args,
|
|
737
|
+
target: t.target,
|
|
738
|
+
on: t.on,
|
|
739
|
+
partialWhere: partialWhereFor(indexes, t.target, t.on),
|
|
740
|
+
columns: [],
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
return found;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* The tables whose rules decide who is asking from a setting the app's own
|
|
748
|
+
* server sets - current_setting('app.tenant_id') - rather than from a
|
|
749
|
+
* Supabase sign-in. Each is a table NOT checked, said with the settings.
|
|
750
|
+
*
|
|
751
|
+
* Found on blind round 13 (Northstar Ops, Express on plain Postgres): every
|
|
752
|
+
* rule read app.tenant_id / app.user_id, which nothing here sets. Every attack
|
|
753
|
+
* ran as nobody, every one was refused for that reason alone, and the report
|
|
754
|
+
* said "Nothing got through" over a table whose rule was USING (true) - the
|
|
755
|
+
* one sentence this program must never print by accident. A rule that also
|
|
756
|
+
* asks Supabase (auth.uid(), request.jwt...) is attacked as usual.
|
|
757
|
+
* Mirrors server_identity in engine.sql.
|
|
758
|
+
*/
|
|
759
|
+
function serverIdentityOf(plan) {
|
|
760
|
+
const byTable = new Map();
|
|
761
|
+
for (const p of (plan && plan.policies) || []) {
|
|
762
|
+
const text = String(p.qual || '') + ' ' + String(p.with_check || '');
|
|
763
|
+
if (/\bauth\s*\.\s*(uid|jwt|role|email)\s*\(|request\.jwt/i.test(text)) continue;
|
|
764
|
+
const re = /current_setting\s*\(\s*'([^']+)'/gi;
|
|
765
|
+
let m;
|
|
766
|
+
while ((m = re.exec(text))) {
|
|
767
|
+
if (/^request\./i.test(m[1])) continue;
|
|
768
|
+
if (!byTable.has(p.table_name)) byTable.set(p.table_name, new Set());
|
|
769
|
+
byTable.get(p.table_name).add(m[1]);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return Array.from(byTable.keys()).sort().map((table) => {
|
|
773
|
+
const settings = Array.from(byTable.get(table)).sort();
|
|
774
|
+
// The why is short and per table; what it means is said once, under the
|
|
775
|
+
// list (notTestedLines), not repeated for every table.
|
|
776
|
+
return {
|
|
777
|
+
table: table,
|
|
778
|
+
why: 'its rules decide who is asking with ' + andList(settings.map((s) => "current_setting('" + s + "')")),
|
|
779
|
+
identity: true,
|
|
780
|
+
};
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* The app's functions that were read and never called: anything a visitor or
|
|
786
|
+
* a signed-in user can call, that no rule calls for them (those ran with every
|
|
787
|
+
* read), and that no finding already names. Said under "What I did not test".
|
|
788
|
+
*
|
|
789
|
+
* Found on a blind test (Northstar): the report on the repository said every
|
|
790
|
+
* attack ran, while upsert_project - broken on every call - and record_scan
|
|
791
|
+
* had never been run at all.
|
|
792
|
+
*/
|
|
793
|
+
function notCalledOf(plan, findings) {
|
|
794
|
+
const escape = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
795
|
+
const rules = ((plan && plan.policies) || []).map((p) => String(p.qual || '') + ' ' + String(p.with_check || '')).join(' ');
|
|
796
|
+
const named = new Set((findings || []).map((f) => f.fn).filter(Boolean));
|
|
797
|
+
const names = ((plan && plan.functions) || [])
|
|
798
|
+
.filter((fn) => fn.client_callable === true && fn.from_extension !== true && !named.has(fn.name))
|
|
799
|
+
.map((fn) => fn.name)
|
|
800
|
+
.filter((name) => !new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?\\s*\\(').test(rules));
|
|
801
|
+
return Array.from(new Set(names)).sort();
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* A public bucket whose read rule says it was meant to be private.
|
|
806
|
+
*
|
|
807
|
+
* Found on two blind tests (OrbitDesk, AtlasPay): a bucket of exports and one
|
|
808
|
+
* of settlement evidence were switched to public. A public bucket hands any
|
|
809
|
+
* file to whoever has its link, and the rules on storage.objects are not asked.
|
|
810
|
+
* A rule that only lets in some people is how the app says the files are not
|
|
811
|
+
* for everyone - so a public bucket with one is worth a look. A rule that lets
|
|
812
|
+
* in everyone anyway (anon or public, and nothing but the bucket's name) says
|
|
813
|
+
* the opposite, and is not reported.
|
|
814
|
+
*/
|
|
815
|
+
function bucketsOf(plan) {
|
|
816
|
+
const escape = (x) => String(x).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
817
|
+
const narrows = (rule, id) => {
|
|
818
|
+
const roles = schema.roleList(rule.roles);
|
|
819
|
+
const toEveryone = roles.includes('anon') || roles.includes('public');
|
|
820
|
+
const rest = String(rule.qual || '')
|
|
821
|
+
.replace(new RegExp("bucket_id\\s*=\\s*'" + escape(id) + "'(::text)?", 'gi'), '')
|
|
822
|
+
.replace(new RegExp("'" + escape(id) + "'(::text)?\\s*=\\s*bucket_id", 'gi'), '')
|
|
823
|
+
.replace(/\btrue\b|\band\b|[()\s]/gi, '');
|
|
824
|
+
return !toEveryone || rest !== '';
|
|
825
|
+
};
|
|
826
|
+
// A rule for putting files in or taking them out that only signed-in people
|
|
827
|
+
// pass AND that looks a file up in the app's own data (a subquery over a
|
|
828
|
+
// table: an attachments row, a membership). That is files the app treats as
|
|
829
|
+
// its members' records. A rule that only asks whose folder it is - the
|
|
830
|
+
// usual public avatars bucket - is not this.
|
|
831
|
+
const membersOnly = (rule) => {
|
|
832
|
+
const roles = schema.roleList(rule.roles);
|
|
833
|
+
const text = String(rule.qual || '') + ' ' + String(rule.with_check || '');
|
|
834
|
+
const open = roles.includes('anon') || roles.includes('public');
|
|
835
|
+
return (!open || /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(text)) && /\bfrom\s+/i.test(text);
|
|
836
|
+
};
|
|
837
|
+
return ((plan && plan.buckets) || []).filter((b) => b.public).map((b) => {
|
|
838
|
+
const writers = (b.writeRules || []).filter(membersOnly).map((rule) => rule.name);
|
|
839
|
+
return {
|
|
840
|
+
kind: 'bucket',
|
|
841
|
+
table: b.name,
|
|
842
|
+
bucket: b.id,
|
|
843
|
+
rules: (b.readRules || []).filter((rule) => narrows(rule, b.id)).map((rule) => rule.name),
|
|
844
|
+
// No read rule at all, and something else saying its files are not for
|
|
845
|
+
// everyone: a name that says what is in it (HarborLine: customer-exports,
|
|
846
|
+
// whose one read rule had failed to load), or rules that let only
|
|
847
|
+
// signed-in members put files in or take them out (blind round 15:
|
|
848
|
+
// workspace-files, written only through an attachments row of the
|
|
849
|
+
// uploader's, and readable by anyone with a link).
|
|
850
|
+
noRule: !(b.readRules || []).length && (PRIVATE_BUCKET.test(String(b.name)) || writers.length > 0),
|
|
851
|
+
writers: !(b.readRules || []).length ? writers : [],
|
|
852
|
+
columns: [],
|
|
853
|
+
};
|
|
854
|
+
}).filter((f) => f.rules.length || f.noRule);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Names that say a bucket's files are somebody's own business. Whole words
|
|
858
|
+
// only: "plain" or "avatars" say nothing, and stay quiet.
|
|
859
|
+
const PRIVATE_BUCKET = /(^|[-_.])(exports?|private|documents?|docs|invoices?|contracts?|backups?|reports?|receipts?|statements?|payouts?|evidence|kyc|passports?|medical|records?|confidential|internal|secrets?|attachments?)([-_.]|$)/i;
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* What each finding needs to know about its table for its fix to be right:
|
|
863
|
+
* whether the app signs people in through Supabase, whether a row says whose
|
|
864
|
+
* it is, and which column that is. Without these a prompt falls back to "the
|
|
865
|
+
* column that says who each row belongs to" - found on a nightly read-back,
|
|
866
|
+
* whose findings came from the database without them.
|
|
867
|
+
*/
|
|
868
|
+
function taggerFor(plan, tables) {
|
|
869
|
+
const ties = finding.authTiesOf(plan);
|
|
870
|
+
const owned = new Map((tables || []).map((table) => [table.name, attack.ownerColumn(table)]));
|
|
871
|
+
return (f) => Object.assign({}, f,
|
|
872
|
+
ties.has(f.table) ? { authTied: ties.get(f.table) } : {},
|
|
873
|
+
owned.has(f.table) ? { owned: Boolean(owned.get(f.table)) } : {},
|
|
874
|
+
owned.get(f.table) ? { ownedBy: owned.get(f.table) } : {});
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* What the nightly engine wrote down as not tried, in the shape a scan uses.
|
|
879
|
+
* It names a column's check as table and column apart, with no attack named,
|
|
880
|
+
* so "export_jobs.access_token could not be raced" was counted as a whole
|
|
881
|
+
* table not checked - and listed without its column.
|
|
882
|
+
*/
|
|
883
|
+
function notCheckedFromNight(entries) {
|
|
884
|
+
return (entries || []).map((e) => {
|
|
885
|
+
if (!e || e.key || !e.column) return e;
|
|
886
|
+
const kind = /two requests at the same instant/.test(String(e.why)) ? 'duplicated' : 'orphaned';
|
|
887
|
+
return { table: e.table + '.' + e.column, key: kind + ':' + e.table + ':' + e.column, why: e.why };
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* A moment of the night, said in UTC - which is what the database runs on -
|
|
893
|
+
* and in this computer's own time, so 03:00 is not read as three at night
|
|
894
|
+
* here. Found on a real read-back: "the night of 2026-10-01 03:00" was 08:30
|
|
895
|
+
* in India, and nothing said so.
|
|
896
|
+
*/
|
|
897
|
+
function bothTimes(iso) {
|
|
898
|
+
const at = new Date(iso);
|
|
899
|
+
return at.toISOString().slice(0, 16).replace('T', ' ') + ' UTC (' +
|
|
900
|
+
at.toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) + ' your time)';
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** "0 3 * * *" as a time of day in both, or the schedule as written. */
|
|
904
|
+
function scheduleTimes(cron) {
|
|
905
|
+
const m = /^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*$/.exec(String(cron || '').trim());
|
|
906
|
+
if (!m) return String(cron || 'whenever it is scheduled');
|
|
907
|
+
const now = new Date();
|
|
908
|
+
const at = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), Number(m[2]), Number(m[1])));
|
|
909
|
+
return 'every night at ' + at.toISOString().slice(11, 16) + ' UTC (' +
|
|
910
|
+
at.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) + ' your time)';
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** Where the nightly run's last answer is kept for the dashboard. */
|
|
914
|
+
function nightlyFile() {
|
|
915
|
+
return store.open(process.cwd()).nightNightly;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* The nightly run's last answer, in the shape the dashboard reads: the same
|
|
920
|
+
* described findings a scan saves, and the three states that are not the same
|
|
921
|
+
* as "nothing found" - not installed, installed and never run, and stopped -
|
|
922
|
+
* said as themselves. `latest` is installer.latestRun's answer.
|
|
923
|
+
*/
|
|
924
|
+
function nightlyRecord(latest, readAt, plan) {
|
|
925
|
+
const at = (value) => (value ? new Date(value).toISOString() : null);
|
|
926
|
+
if (!latest || !latest.installed) return { readAt: readAt, installed: false };
|
|
927
|
+
const where = latest.where || {};
|
|
928
|
+
const record = {
|
|
929
|
+
readAt: readAt,
|
|
930
|
+
installed: true,
|
|
931
|
+
scheduled: where.job ? where.job.schedule : null,
|
|
932
|
+
active: Boolean(where.job && where.job.active),
|
|
933
|
+
source: where.source || null,
|
|
934
|
+
ranAt: null,
|
|
935
|
+
};
|
|
936
|
+
const run = latest.run;
|
|
937
|
+
if (!run) return record;
|
|
938
|
+
// The night's findings, told as the night's: tagged with what their table
|
|
939
|
+
// needs for a right fix when the schema could be read, and joined by the
|
|
940
|
+
// functions anyone can call and the public buckets - read now, when this is
|
|
941
|
+
// asked, which is newer than the night. The night records them too since
|
|
942
|
+
// 0.1.32 (for the dashboard), so where they can be read now the night's own
|
|
943
|
+
// are left out rather than said twice; where they cannot, the night's stand.
|
|
944
|
+
const tag = plan ? taggerFor(plan, plan.tables) : (f) => f;
|
|
945
|
+
// A loop the engine could not name ("stack depth limit exceeded") is put
|
|
946
|
+
// on the tables whose own rule reads back into them, read from the shape.
|
|
947
|
+
const loops = plan ? loopingTables(plan) : [];
|
|
948
|
+
const night = [];
|
|
949
|
+
for (const f of run.findings || []) {
|
|
950
|
+
if (plan && (f.kind === 'privileged' || f.kind === 'bucket')) continue;
|
|
951
|
+
const at = f.kind === 'recursive' && !f.table ? (loops.length ? loops : ['one of your tables']) : [f.table];
|
|
952
|
+
for (const table of at) night.push(tag(Object.assign({}, f, { table: table, fromNight: true })));
|
|
953
|
+
}
|
|
954
|
+
const now = plan ? privilegedOf(plan).concat(bucketsOf(plan)).map(tag) : [];
|
|
955
|
+
return Object.assign(record, {
|
|
956
|
+
ranAt: at(run.ran_at),
|
|
957
|
+
source: run.source,
|
|
958
|
+
stopped: run.stopped || null,
|
|
959
|
+
attacksRun: run.attacks_run || 0,
|
|
960
|
+
notChecked: notCheckedFromNight(run.not_checked),
|
|
961
|
+
notCalled: run.not_called || [],
|
|
962
|
+
findings: run.stopped ? [] : finding.describeAll(night.concat(now)),
|
|
963
|
+
functionsReadAt: plan ? readAt : null,
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** Kept for the dashboard. A failure here never touches the run itself. */
|
|
968
|
+
function saveNightly(file, record) {
|
|
969
|
+
try {
|
|
970
|
+
fs.writeFileSync(file, JSON.stringify(record, null, 2));
|
|
971
|
+
return true;
|
|
972
|
+
} catch (err) {
|
|
973
|
+
return false;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/** The last run, or null if there has not been one worth keeping. */
|
|
978
|
+
function loadLastRun(file) {
|
|
979
|
+
try {
|
|
980
|
+
const saved = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
981
|
+
return saved && Array.isArray(saved.findings) ? saved : null;
|
|
982
|
+
} catch (err) {
|
|
983
|
+
return null;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Keeps a run so the next one can be measured against it.
|
|
989
|
+
*
|
|
990
|
+
* A run that stopped is never saved. Overwriting a real list of problems with
|
|
991
|
+
* an empty one from a run that fell over would quietly lose every finding, and
|
|
992
|
+
* the re-check after that would have nothing to hold anybody to.
|
|
993
|
+
*/
|
|
994
|
+
function saveRun(file, result) {
|
|
995
|
+
if (result.stopped) return;
|
|
996
|
+
try {
|
|
997
|
+
fs.writeFileSync(file, JSON.stringify(result, null, 2));
|
|
998
|
+
} catch (err) {
|
|
999
|
+
line(' WARNING: I could not save this run to ' + file + ', so tomorrow I');
|
|
1000
|
+
line(' will have nothing to compare against: ' + err.message);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* The last thing on the screen: what was not tested, and why.
|
|
1006
|
+
*
|
|
1007
|
+
* A report that ends on "nothing got through" is read as "I am safe", and the
|
|
1008
|
+
* distance between those two sentences is the whole product. So the last
|
|
1009
|
+
* words are always the limits of the answer, not the answer.
|
|
1010
|
+
*
|
|
1011
|
+
* It is printed even when nothing was missed, because "I tested all of it"
|
|
1012
|
+
* and "I did not tell you what I skipped" look identical when the section is
|
|
1013
|
+
* simply absent, and only one of them is good news.
|
|
1014
|
+
*/
|
|
1015
|
+
function notTestedLines(result) {
|
|
1016
|
+
const notChecked = result.notChecked || [];
|
|
1017
|
+
const lines = ['', ' ' + '-'.repeat(68), ' What I did not test', ''];
|
|
1018
|
+
|
|
1019
|
+
if (!notChecked.length) {
|
|
1020
|
+
lines.push(' Every table I could reach was attacked, and every attack I have');
|
|
1021
|
+
lines.push(' finished, ran.');
|
|
1022
|
+
} else {
|
|
1023
|
+
// Wrapped: a reason can be a sentence or two (serverIdentityOf), and an
|
|
1024
|
+
// unwrapped one ran off the side of the terminal and the dashboard.
|
|
1025
|
+
for (const missed of notChecked) {
|
|
1026
|
+
wrap(missed.table + ' - ' + missed.why, 66).forEach((l, i) => lines.push((i ? ' ' : ' ') + l));
|
|
1027
|
+
}
|
|
1028
|
+
if (notChecked.some((missed) => missed.identity)) {
|
|
1029
|
+
lines.push('');
|
|
1030
|
+
wrap('Those settings are set by your own server, not by a Supabase sign-in. I sign in the way Supabase ' +
|
|
1031
|
+
'does, so every attack on those tables ran as nobody, and being refused proves nothing about them. ' +
|
|
1032
|
+
'Check those rules by hand, or test them with your server setting who is asking.', 66)
|
|
1033
|
+
.forEach((l) => lines.push(' ' + l));
|
|
1034
|
+
}
|
|
1035
|
+
lines.push('');
|
|
1036
|
+
lines.push(' These are unknown, not clear. An attack that never ran comes back');
|
|
1037
|
+
lines.push(' looking exactly like an attack that was refused.');
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Found on a blind test (Northstar): "every attack ran" was printed over an
|
|
1041
|
+
// upsert that failed on every call, because no attack calls the app's own
|
|
1042
|
+
// functions. They are named, so the sentence above is read as being about
|
|
1043
|
+
// tables only.
|
|
1044
|
+
const notCalled = result.notCalled || [];
|
|
1045
|
+
if (notCalled.length) {
|
|
1046
|
+
lines.push('');
|
|
1047
|
+
wrap('Your app\'s ' + (notCalled.length === 1 ? 'function ' : 'functions ') +
|
|
1048
|
+
andList(notCalled.map((name) => name + '()')) + (notCalled.length === 1 ? ' was' : ' were') +
|
|
1049
|
+
' read, never called. Whether ' + (notCalled.length === 1 ? 'it works' : 'they work') +
|
|
1050
|
+
', and what ' + (notCalled.length === 1 ? 'it lets' : 'they let') + ' a caller do, is not tested here.', 68)
|
|
1051
|
+
.forEach((l) => lines.push(' ' + l));
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Said every time, whether or not the collision attack ran. It is the one
|
|
1055
|
+
// limit that is structural rather than circumstantial, and a person
|
|
1056
|
+
// deciding how far to trust this deserves to know it is there.
|
|
1057
|
+
lines.push('');
|
|
1058
|
+
lines.push(' One thing is never reported, on purpose: the lost update - two');
|
|
1059
|
+
lines.push(' withdrawals of 100 from a balance of 100 that both go through.');
|
|
1060
|
+
lines.push(' Every Postgres database behaves that way unless the app asks it');
|
|
1061
|
+
lines.push(' not to, so whether yours is affected depends on code I never see.');
|
|
1062
|
+
lines.push(' Flagging it would mean flagging every app with a number in it.');
|
|
1063
|
+
lines.push('');
|
|
1064
|
+
|
|
1065
|
+
return lines;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* The warning above the verdict: how much went untested, counted honestly.
|
|
1070
|
+
*
|
|
1071
|
+
* Every entry used to be counted as a table. Found on a benchmark: one
|
|
1072
|
+
* column's duplicate test did not run, and the top line said "1 table was
|
|
1073
|
+
* NOT checked" about a table every other attack had covered - and a table
|
|
1074
|
+
* with two attacks stuck was counted as two tables. An entry with no attack
|
|
1075
|
+
* named is a whole table nothing could be put into; one with an attack named
|
|
1076
|
+
* is part of a table.
|
|
1077
|
+
*/
|
|
1078
|
+
function missedLines(notChecked) {
|
|
1079
|
+
const tableOf = (entry) => String(entry.table).split('.')[0];
|
|
1080
|
+
const whole = new Set(notChecked.filter((entry) => !entry.key).map(tableOf));
|
|
1081
|
+
const parts = notChecked.filter((entry) => entry.key && !whole.has(tableOf(entry)));
|
|
1082
|
+
const partly = new Set(parts.map(tableOf));
|
|
1083
|
+
const tables = (n) => n + (n === 1 ? ' table' : ' tables');
|
|
1084
|
+
const said = [];
|
|
1085
|
+
if (whole.size) said.push(tables(whole.size) + (whole.size === 1 ? ' was' : ' were') + ' NOT checked');
|
|
1086
|
+
if (parts.length) {
|
|
1087
|
+
said.push(parts.length + (parts.length === 1 ? ' check' : ' checks') + ' on ' +
|
|
1088
|
+
(whole.size ? tables(partly.size).replace(/ table/, ' other table') : tables(partly.size)) +
|
|
1089
|
+
(parts.length === 1 ? ' was' : ' were') + ' NOT run');
|
|
1090
|
+
}
|
|
1091
|
+
const it = notChecked.length === 1 ? 'it' : 'them';
|
|
1092
|
+
return wrap(said.join(', and ') + '. Whatever this report says next is not about ' + it + '. Treat ' +
|
|
1093
|
+
it + ' as unknown, not as clear - the list, and why, is at the end.', 68).map((l) => ' ' + l);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/** The report, as the person reads it in the morning. */
|
|
1097
|
+
function report(result) {
|
|
1098
|
+
if (result.stopped) {
|
|
1099
|
+
line('');
|
|
1100
|
+
line(' I could not check this app.');
|
|
1101
|
+
line('');
|
|
1102
|
+
line(' ' + result.stopped);
|
|
1103
|
+
line('');
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Said before anything else, and said even on a clean night. "Everything
|
|
1108
|
+
// held" is only true about the tables that were actually tried, and a table
|
|
1109
|
+
// no row could be put into looks exactly like a table nothing got out of.
|
|
1110
|
+
const notChecked = result.notChecked || [];
|
|
1111
|
+
// Said before the verdict, and again in full at the very end. The warning
|
|
1112
|
+
// has to come first or "everything held" is read before the reason it might
|
|
1113
|
+
// not mean anything; the list has to come last or it is the first thing
|
|
1114
|
+
// scrolled past. The two are not the same sentence twice - this one is
|
|
1115
|
+
// whether to trust the verdict, and the one at the end is what to go and
|
|
1116
|
+
// look at.
|
|
1117
|
+
const sayWhatWasMissed = () => {
|
|
1118
|
+
if (!notChecked.length) return;
|
|
1119
|
+
line('');
|
|
1120
|
+
missedLines(notChecked).forEach(line);
|
|
1121
|
+
};
|
|
1122
|
+
|
|
1123
|
+
if (!result.findings.length) {
|
|
1124
|
+
if (notChecked.length) {
|
|
1125
|
+
sayWhatWasMissed();
|
|
1126
|
+
line('');
|
|
1127
|
+
line(' Everything I could try, held.');
|
|
1128
|
+
line('');
|
|
1129
|
+
} else {
|
|
1130
|
+
finding.allClearLines(result.attacksRun).forEach(line);
|
|
1131
|
+
}
|
|
1132
|
+
notTestedLines(result).forEach(line);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
sayWhatWasMissed();
|
|
1137
|
+
|
|
1138
|
+
// Confirmed breaks and things that only need a look are not the same claim,
|
|
1139
|
+
// so they are counted apart. Saying "3 problems" when one of them was never
|
|
1140
|
+
// executed is the kind of overstatement that costs the next report its trust.
|
|
1141
|
+
const confirmed = result.findings.filter((f) => f.status !== 'verification required');
|
|
1142
|
+
const toVerify = result.findings.filter((f) => f.status === 'verification required');
|
|
1143
|
+
line('');
|
|
1144
|
+
if (confirmed.length) {
|
|
1145
|
+
line(' ' + confirmed.length + (confirmed.length === 1 ? ' problem' : ' problems') + ' found.');
|
|
1146
|
+
}
|
|
1147
|
+
if (toVerify.length) {
|
|
1148
|
+
// A viewer who can edit was really tried; a function or a bucket was only
|
|
1149
|
+
// read. Either way it is the owner's call, and the line says which.
|
|
1150
|
+
const tried = toVerify.some((f) => f.kind === 'role' || f.kind === 'teamread');
|
|
1151
|
+
const spotted = toVerify.some((f) => f.kind !== 'role' && f.kind !== 'teamread');
|
|
1152
|
+
const it = toVerify.length === 1 ? 'it' : 'them';
|
|
1153
|
+
line(' ' + toVerify.length + ' thing' + (toVerify.length === 1 ? '' : 's') + ' to check - ' +
|
|
1154
|
+
(spotted && !tried ? 'I did not attack ' + it + ', only spotted the risk.'
|
|
1155
|
+
: 'whether ' + (toVerify.length === 1 ? 'it is a problem' : 'each is a problem') + ' is your decision.'));
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
for (const item of result.findings) {
|
|
1159
|
+
line('');
|
|
1160
|
+
line(' ' + '-'.repeat(68));
|
|
1161
|
+
line(' ' + item.severity + ' ' + item.table +
|
|
1162
|
+
(item.status === 'verification required' ? ' (verification required)' : ''));
|
|
1163
|
+
line('');
|
|
1164
|
+
line(' ' + item.headline);
|
|
1165
|
+
line('');
|
|
1166
|
+
for (const paragraph of [item.body, item.cause].filter(Boolean)) {
|
|
1167
|
+
wrap(paragraph, 70).forEach((l) => line(' ' + l));
|
|
1168
|
+
line('');
|
|
1169
|
+
}
|
|
1170
|
+
line(' Paste this into Lovable, Claude or Cursor:');
|
|
1171
|
+
line('');
|
|
1172
|
+
item.fixPrompt.split('\n').forEach((l) => line(' ' + l));
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
line('');
|
|
1176
|
+
line(' ' + '-'.repeat(68));
|
|
1177
|
+
line(' Nothing here touched your live app. I built a copy, attacked the copy,');
|
|
1178
|
+
line(' and deleted it. Not one real customer was involved.');
|
|
1179
|
+
|
|
1180
|
+
notTestedLines(result).forEach(line);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/** Wraps at a width a person can read without their eyes sliding off. */
|
|
1184
|
+
/** a, b and c */
|
|
1185
|
+
function andList(items) {
|
|
1186
|
+
return items.length < 2 ? items.join('') : items.slice(0, -1).join(', ') + ' and ' + items[items.length - 1];
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function wrap(text, width) {
|
|
1190
|
+
const words = String(text).split(/\s+/);
|
|
1191
|
+
const lines = [];
|
|
1192
|
+
let current = '';
|
|
1193
|
+
for (const word of words) {
|
|
1194
|
+
if ((current + ' ' + word).trim().length > width) {
|
|
1195
|
+
lines.push(current.trim());
|
|
1196
|
+
current = word;
|
|
1197
|
+
} else {
|
|
1198
|
+
current = (current + ' ' + word).trim();
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
if (current) lines.push(current.trim());
|
|
1202
|
+
return lines;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
async function main() {
|
|
1206
|
+
const args = process.argv.slice(2).filter((arg) => arg !== '--recheck');
|
|
1207
|
+
const again = process.argv.includes('--recheck');
|
|
1208
|
+
|
|
1209
|
+
// The connection string is the key to the customer's whole database, and
|
|
1210
|
+
// typed on the command line it goes into shell history and into the process
|
|
1211
|
+
// list where anybody on the machine can read it. The environment variable is
|
|
1212
|
+
// the way it should be given; the argument still works because taking it
|
|
1213
|
+
// away would break anyone already using it.
|
|
1214
|
+
const fromEnvironment = process.env.KN_DATABASE_URL;
|
|
1215
|
+
const connection = args.length > 1 ? args[0] : fromEnvironment;
|
|
1216
|
+
const target = args.length > 1 ? args[1] : args[0];
|
|
1217
|
+
|
|
1218
|
+
if (!connection || !target) {
|
|
1219
|
+
console.error('');
|
|
1220
|
+
console.error(' KN_DATABASE_URL=postgresql://... node scan.js <schema> [--recheck]');
|
|
1221
|
+
console.error(' node scan.js "<connection string>" <schema> [--recheck]');
|
|
1222
|
+
console.error('');
|
|
1223
|
+
console.error(' <schema> is usually "public".');
|
|
1224
|
+
console.error('');
|
|
1225
|
+
process.exit(2);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
let file;
|
|
1229
|
+
try {
|
|
1230
|
+
file = lastRunFile(line);
|
|
1231
|
+
} catch (err) {
|
|
1232
|
+
// 2, not 1: nothing was attacked, so nothing "got through".
|
|
1233
|
+
console.error('');
|
|
1234
|
+
console.error(' There is nowhere to keep this run (' + err.message + ').');
|
|
1235
|
+
console.error(' Set KRYPTHEON_HOME to a folder you can write to, and run this again.');
|
|
1236
|
+
console.error('');
|
|
1237
|
+
process.exit(2);
|
|
1238
|
+
}
|
|
1239
|
+
const before = again ? loadLastRun(file) : null;
|
|
1240
|
+
if (again && !before) {
|
|
1241
|
+
// Running a fresh scan and calling it a re-check would report every
|
|
1242
|
+
// problem as new and confirm nothing, which reads like an answer.
|
|
1243
|
+
console.error('');
|
|
1244
|
+
console.error(' There is no earlier run here to compare against.');
|
|
1245
|
+
console.error('');
|
|
1246
|
+
console.error(' Run it without --recheck first, fix what it finds, then come back.');
|
|
1247
|
+
console.error('');
|
|
1248
|
+
process.exit(2);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const client = new Client({ connectionString: connection });
|
|
1252
|
+
await client.connect();
|
|
1253
|
+
try {
|
|
1254
|
+
const result = await scan(client, target, {
|
|
1255
|
+
// Two requests arriving together cannot be faked down one connection, so
|
|
1256
|
+
// the collision attack is handed a way to open its own.
|
|
1257
|
+
openSession: async () => {
|
|
1258
|
+
const extra = new Client({ connectionString: connection });
|
|
1259
|
+
await extra.connect();
|
|
1260
|
+
return extra;
|
|
1261
|
+
},
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
if (!before) {
|
|
1265
|
+
report(result);
|
|
1266
|
+
saveRun(file, result);
|
|
1267
|
+
// Three answers, three codes: 0 clean, 1 problems found, 2 could not
|
|
1268
|
+
// run. A scan that stopped used to exit 0 with no findings, which is
|
|
1269
|
+
// what any script watching it would read as a pass.
|
|
1270
|
+
process.exitCode = exitCodeFor(result);
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
const verdict = recheck.compare(before, result);
|
|
1275
|
+
recheck.describe(verdict).forEach(line);
|
|
1276
|
+
recheck.badgeLines(verdict, result.attacksRun || 0).forEach(line);
|
|
1277
|
+
// The verdict says what changed; this says what to do about what did not.
|
|
1278
|
+
if (result.findings.length) report(result);
|
|
1279
|
+
else notTestedLines(result).forEach(line);
|
|
1280
|
+
saveRun(file, result);
|
|
1281
|
+
// Two different databases: the verdict is a comparison, so the exit code
|
|
1282
|
+
// is this run's own answer, the same as a first run.
|
|
1283
|
+
process.exitCode = result.stopped ? 2 : verdict.elsewhere ? exitCodeFor(result) : verdict.allClear ? 0 : 1;
|
|
1284
|
+
} finally {
|
|
1285
|
+
await client.end();
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
if (require.main === module) {
|
|
1290
|
+
main().catch((err) => {
|
|
1291
|
+
console.error('');
|
|
1292
|
+
console.error(' The scan could not run: ' + err.message);
|
|
1293
|
+
console.error('');
|
|
1294
|
+
process.exit(1);
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
module.exports = {
|
|
1299
|
+
pickSchema: pickSchema,
|
|
1300
|
+
scan: scan,
|
|
1301
|
+
report: report,
|
|
1302
|
+
missedLines: missedLines,
|
|
1303
|
+
wrap: wrap,
|
|
1304
|
+
loadLastRun: loadLastRun,
|
|
1305
|
+
saveRun: saveRun,
|
|
1306
|
+
nightlyFile: nightlyFile,
|
|
1307
|
+
nightlyRecord: nightlyRecord,
|
|
1308
|
+
bothTimes: bothTimes,
|
|
1309
|
+
scheduleTimes: scheduleTimes,
|
|
1310
|
+
notCheckedFromNight: notCheckedFromNight,
|
|
1311
|
+
privilegedOf: privilegedOf,
|
|
1312
|
+
searchPathsOf: searchPathsOf,
|
|
1313
|
+
emptyChoicesOf: emptyChoicesOf,
|
|
1314
|
+
noInsertsOf: noInsertsOf,
|
|
1315
|
+
oneOfColumns: oneOfColumns,
|
|
1316
|
+
conflictTargetsOf: conflictTargetsOf,
|
|
1317
|
+
partialWhereFor: partialWhereFor,
|
|
1318
|
+
conflictsIn: conflictsIn,
|
|
1319
|
+
notCalledOf: notCalledOf,
|
|
1320
|
+
serverIdentityOf: serverIdentityOf,
|
|
1321
|
+
bucketsOf: bucketsOf,
|
|
1322
|
+
loopingTables: loopingTables,
|
|
1323
|
+
taggerFor: taggerFor,
|
|
1324
|
+
saveNightly: saveNightly,
|
|
1325
|
+
sweepOldCopies: sweepOldCopies,
|
|
1326
|
+
notTestedLines: notTestedLines,
|
|
1327
|
+
lastRunFile: lastRunFile,
|
|
1328
|
+
exitCodeFor: exitCodeFor,
|
|
1329
|
+
ABANDONED_AFTER: ABANDONED_AFTER,
|
|
1330
|
+
};
|