kryptheon-night 0.1.22 → 0.1.26
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/README.md +11 -0
- package/attack.js +172 -29
- package/bin/kryptheon-night.js +2 -2
- package/engine.sql +3235 -2628
- package/finding.js +129 -8
- package/intro.js +5 -1
- package/orphan.js +5 -37
- package/package.json +8 -3
- package/recheck.js +4 -0
- package/scan.js +111 -11
- package/schema.js +184 -9
- package/sqlengine.js +12 -1
- package/tamper.js +218 -1
- package/trouble.js +13 -1
package/finding.js
CHANGED
|
@@ -88,6 +88,11 @@ function listOf(items) {
|
|
|
88
88
|
return list.slice(0, -1).join(', ') + ' and ' + list[list.length - 1];
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/** One of a table's things: workspaces -> workspace, companies -> company. */
|
|
92
|
+
function oneOf(table) {
|
|
93
|
+
return String(table || 'team').replace(/ies$/, 'y').replace(/s$/, '');
|
|
94
|
+
}
|
|
95
|
+
|
|
91
96
|
/* --------------------------------------------------------------------------
|
|
92
97
|
How bad it is.
|
|
93
98
|
-------------------------------------------------------------------------- */
|
|
@@ -105,6 +110,11 @@ function severityOf(finding, contents) {
|
|
|
105
110
|
// because the app is broken for whoever it fails for, but not a break-in.
|
|
106
111
|
return 'HIGH';
|
|
107
112
|
}
|
|
113
|
+
if (finding.kind === 'role' || finding.kind === 'bucket') {
|
|
114
|
+
// Something to check, never a confirmed break: whether a viewer may edit,
|
|
115
|
+
// or a bucket may be public, is the owner's decision. So never CRITICAL.
|
|
116
|
+
return 'HIGH';
|
|
117
|
+
}
|
|
108
118
|
if (finding.kind === 'privileged') {
|
|
109
119
|
// Not proven, so never CRITICAL: this is a reach that exists, not a break
|
|
110
120
|
// that was demonstrated. A function anon can call that also writes is worth
|
|
@@ -291,6 +301,17 @@ function causeOf(finding) {
|
|
|
291
301
|
/** What a caller could do, written the way a person would say it. */
|
|
292
302
|
const WRITE_WORDS = { add: 'add rows to', change: 'change rows in', delete: 'delete rows from' };
|
|
293
303
|
|
|
304
|
+
/**
|
|
305
|
+
* The error a looping rule produces, as Postgres words it. A rule that reads
|
|
306
|
+
* its own table directly is caught as recursion and named; one that reaches
|
|
307
|
+
* it through a helper function runs out of stack, and Postgres names nothing.
|
|
308
|
+
*/
|
|
309
|
+
function loopError(finding) {
|
|
310
|
+
return finding.deep
|
|
311
|
+
? '"stack depth limit exceeded"'
|
|
312
|
+
: '"infinite recursion detected in policy for relation ' + finding.table + '"';
|
|
313
|
+
}
|
|
314
|
+
|
|
294
315
|
/** Who a looping rule fails for, said the way the headline and body need it. */
|
|
295
316
|
function loopVictims(finding) {
|
|
296
317
|
const callers = finding.callers || [];
|
|
@@ -321,6 +342,15 @@ function headlineFor(finding) {
|
|
|
321
342
|
if (finding.kind === 'privileged') {
|
|
322
343
|
return 'Anyone on the internet can call your ' + finding.fn + ' function, and it runs with full rights.';
|
|
323
344
|
}
|
|
345
|
+
if (finding.kind === 'role') {
|
|
346
|
+
const order = ['delete', 'change', 'add'];
|
|
347
|
+
const does = order.filter((what) => (finding.can || []).includes(what)).map((what) => WRITE_WORDS[what]);
|
|
348
|
+
return 'A "' + finding.who + '" in a ' + oneOf(finding.parent) + ' can ' + listOf(does) + ' your ' +
|
|
349
|
+
finding.table + ' table.';
|
|
350
|
+
}
|
|
351
|
+
if (finding.kind === 'bucket') {
|
|
352
|
+
return 'Your ' + finding.table + ' storage bucket is public, but a rule says only some people may read it.';
|
|
353
|
+
}
|
|
324
354
|
if (finding.kind === 'exposed') {
|
|
325
355
|
return 'Your ' + finding.table + ' ' + (finding.isView ? 'view' : 'table') + ' can be read by anyone.';
|
|
326
356
|
}
|
|
@@ -363,8 +393,9 @@ function bodyFor(finding, contents) {
|
|
|
363
393
|
const one = reads.length === 1;
|
|
364
394
|
return (
|
|
365
395
|
'Using ' + listOf(reads) + ' on a copy of your app as ' + as.join(' and as ') +
|
|
366
|
-
', every request was refused with
|
|
367
|
-
finding.
|
|
396
|
+
', every request was refused with ' + loopError(finding) +
|
|
397
|
+
(finding.deep ? ' - a rule on ' + finding.table + ' calls a helper that reads ' + finding.table + ' again, which runs the rule again' : '') +
|
|
398
|
+
'. Every page in your app that uses ' + (one ? 'that table' : 'those tables') +
|
|
368
399
|
' fails the same way for ' + loopVictims(finding).replace(/,$/, '') + '. It also means I could ' +
|
|
369
400
|
'not test ' + (one ? 'it' : 'them') + ' for leaks - those attacks are listed at the end.'
|
|
370
401
|
);
|
|
@@ -403,6 +434,36 @@ function bodyFor(finding, contents) {
|
|
|
403
434
|
'kept, and your live app was never touched.';
|
|
404
435
|
}
|
|
405
436
|
|
|
437
|
+
if (finding.kind === 'role') {
|
|
438
|
+
// Said as what was done, and then whose decision it is: a viewer who may
|
|
439
|
+
// edit is a hole in one app and the design of another.
|
|
440
|
+
const did = [];
|
|
441
|
+
const rows = (n) => n + (n === 1 ? ' row' : ' rows');
|
|
442
|
+
if ((finding.can || []).includes('change')) did.push('changed ' + rows((finding.changed || {}).change || 1));
|
|
443
|
+
if ((finding.can || []).includes('delete')) did.push('deleted ' + rows((finding.changed || {}).delete || 1));
|
|
444
|
+
const place = oneOf(finding.parent);
|
|
445
|
+
return (
|
|
446
|
+
'I put one of my fake people into the other one\'s ' + place + ' as a "' + finding.who +
|
|
447
|
+
'" - the lowest role in ' + finding.via + '.' + finding.roleColumn + ' - and, signed in as them, I ' +
|
|
448
|
+
listOf(did) + ' in ' + finding.table + ' that belonged to that ' + place + ', on a copy of your app. ' +
|
|
449
|
+
'Before joining, they could not. Every one of those was undone straight away. Whether a "' + finding.who +
|
|
450
|
+
'" may do this is your decision: if it is meant to, nothing needs fixing; if a "' + finding.who +
|
|
451
|
+
'" should only look, the rules on this table let them do more.'
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (finding.kind === 'bucket') {
|
|
456
|
+
// Said as what was read, never as a file taken: no file was opened.
|
|
457
|
+
return (
|
|
458
|
+
'In Supabase Storage the bucket "' + finding.bucket + '" is set to public, so anyone with a ' +
|
|
459
|
+
'file\'s link can download it without logging in - the rules on storage.objects are not asked. ' +
|
|
460
|
+
'You also wrote ' + ((finding.rules || []).length === 1 ? 'a rule' : 'rules') + ' saying who may read it (' +
|
|
461
|
+
listOf(finding.rules || []) + '), so it was probably meant to be private. I only read the ' +
|
|
462
|
+
'bucket\'s setting and its rules; I did not open or list any file. Whether it should be public ' +
|
|
463
|
+
'is yours to say.'
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
406
467
|
if (finding.kind === 'privileged') {
|
|
407
468
|
// Said as what is true, not as what was done - nothing was executed. The
|
|
408
469
|
// reach is real; whether it is a mistake is the person's to say, so the
|
|
@@ -662,8 +723,8 @@ function fixPromptFor(finding) {
|
|
|
662
723
|
'My app has a broken database rule.',
|
|
663
724
|
'',
|
|
664
725
|
'A row level security policy on the "' + finding.table + '" table reads "' + finding.table +
|
|
665
|
-
'" itself
|
|
666
|
-
'
|
|
726
|
+
'" itself' + (finding.deep ? ', through a helper function it calls,' : '') +
|
|
727
|
+
' so Postgres stops every request that touches it with ' + loopError(finding) + '. I saw it when using ' +
|
|
667
728
|
listOf((finding.reads || []).map((t) => '"' + t + '"')) + '.',
|
|
668
729
|
'',
|
|
669
730
|
'Show me the policies on "' + finding.table + '" and on every table whose policy reads "' +
|
|
@@ -682,6 +743,50 @@ function fixPromptFor(finding) {
|
|
|
682
743
|
'Then sign in as an ordinary user and open the pages that use these tables, and run this ' +
|
|
683
744
|
'check again - the tables it could not test will be tested this time.',
|
|
684
745
|
]
|
|
746
|
+
: finding.kind === 'role'
|
|
747
|
+
? [
|
|
748
|
+
'My app may have a permissions problem - please check it rather than assume it.',
|
|
749
|
+
'',
|
|
750
|
+
'In my database, a member of a ' + oneOf(finding.parent) + ' whose role in "' + finding.via + '"."' +
|
|
751
|
+
finding.roleColumn + '" is "' + finding.who + '" can ' +
|
|
752
|
+
listOf((finding.can || []).map((what) => ({ change: 'change', delete: 'delete' })[what])) +
|
|
753
|
+
' rows of that ' + oneOf(finding.parent) + ' in the "' + finding.table + '" table. This was proved ' +
|
|
754
|
+
'on a copy of the database: a "' + finding.who + '" was added, and the writes went through.',
|
|
755
|
+
'',
|
|
756
|
+
'First tell me: should a "' + finding.who + '" be able to do that? If yes, change nothing.',
|
|
757
|
+
'',
|
|
758
|
+
'If not, make the rules on "' + finding.table + '" that cover UPDATE, DELETE, INSERT or ALL check ' +
|
|
759
|
+
'the member\'s role as well as membership - for example with a helper that reads "' + finding.via +
|
|
760
|
+
'" for auth.uid() and the roles allowed to edit. Postgres lets a write through if ANY permissive ' +
|
|
761
|
+
'policy allows it, so a strict rule beside a loose one does nothing: list every such policy on "' +
|
|
762
|
+
finding.table + '" and narrow or drop the loose one. Keep the read rule as it is if a "' +
|
|
763
|
+
finding.who + '" should still see these rows.',
|
|
764
|
+
'',
|
|
765
|
+
'Then check every other table where members can write for the same thing.',
|
|
766
|
+
]
|
|
767
|
+
: finding.kind === 'bucket'
|
|
768
|
+
? [
|
|
769
|
+
'My app may have a storage problem - please check it rather than assume it.',
|
|
770
|
+
'',
|
|
771
|
+
'The Supabase Storage bucket "' + finding.bucket + '" is public. Anyone with a file\'s URL can ' +
|
|
772
|
+
'download it without logging in, and the storage.objects ' +
|
|
773
|
+
((finding.rules || []).length === 1 ? 'policy that names' : 'policies that name') + ' this bucket (' +
|
|
774
|
+
listOf((finding.rules || []).map((r) => '"' + r + '"')) + ') ' +
|
|
775
|
+
((finding.rules || []).length === 1 ? 'is' : 'are') + ' not checked for public URLs - so ' +
|
|
776
|
+
((finding.rules || []).length === 1 ? 'it protects' : 'they protect') + ' nothing while it is public.',
|
|
777
|
+
'',
|
|
778
|
+
'First tell me: is every file in this bucket meant to be downloadable by anyone? If yes, change nothing.',
|
|
779
|
+
'',
|
|
780
|
+
'If not, make the bucket private (in the Supabase dashboard: Storage, edit the bucket, turn Public ' +
|
|
781
|
+
'off - or UPDATE storage.buckets SET public = false WHERE id = \'' + finding.bucket + '\'). Then ' +
|
|
782
|
+
'change the app to fetch these files with signed URLs (createSignedUrl) instead of getPublicUrl, ' +
|
|
783
|
+
'or the links it shows will stop working.',
|
|
784
|
+
'',
|
|
785
|
+
'Keep the read rule: once the bucket is private it is what decides who gets a file. Check that it ' +
|
|
786
|
+
'only lets in the right people.',
|
|
787
|
+
'',
|
|
788
|
+
'Then look at every other bucket for the same thing.',
|
|
789
|
+
]
|
|
685
790
|
: finding.kind === 'privileged'
|
|
686
791
|
? [
|
|
687
792
|
'My app may have a security problem - please check it rather than assume it.',
|
|
@@ -851,6 +956,10 @@ function fixPromptFor(finding) {
|
|
|
851
956
|
.join('\n');
|
|
852
957
|
}
|
|
853
958
|
|
|
959
|
+
// Found and said, but never claimed as a break: the owner decides. Counted
|
|
960
|
+
// apart in the report, never towards the exit code or a clean re-check.
|
|
961
|
+
const TO_CHECK = ['privileged', 'role', 'bucket'];
|
|
962
|
+
|
|
854
963
|
/** Everything the report needs about one thing the attack found. */
|
|
855
964
|
function describe(finding) {
|
|
856
965
|
const contents = readContents(finding.columns);
|
|
@@ -861,7 +970,7 @@ function describe(finding) {
|
|
|
861
970
|
// one the tool reasons about without executing, so it says so. The report
|
|
862
971
|
// and the re-check both read this: a "verification required" finding is
|
|
863
972
|
// never counted towards a clean re-check on its own.
|
|
864
|
-
status: finding.kind
|
|
973
|
+
status: TO_CHECK.includes(finding.kind) ? 'verification required' : 'confirmed',
|
|
865
974
|
table: finding.kind === 'privileged' ? finding.fn : finding.table,
|
|
866
975
|
fn: finding.fn,
|
|
867
976
|
args: finding.args,
|
|
@@ -880,14 +989,26 @@ function describe(finding) {
|
|
|
880
989
|
// For a privileged function there is no "why the door is open" the way a
|
|
881
990
|
// table has one; the body already carries the whole explanation, so the
|
|
882
991
|
// cause line would only repeat it. Left empty and skipped by the report.
|
|
883
|
-
cause: finding.kind
|
|
992
|
+
cause: TO_CHECK.includes(finding.kind) ? '' : cause.long,
|
|
884
993
|
who: finding.who,
|
|
885
994
|
can: finding.can,
|
|
995
|
+
via: finding.via,
|
|
996
|
+
roleColumn: finding.roleColumn,
|
|
997
|
+
bucket: finding.bucket,
|
|
886
998
|
proof: finding.kind === 'recursive'
|
|
887
999
|
? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
|
|
888
|
-
|
|
1000
|
+
loopError(finding) + '.'
|
|
889
1001
|
: finding.kind === 'privileged'
|
|
890
1002
|
? 'I did not call "' + finding.fn + '". This is a reach that exists in the grants, not a break I ran.'
|
|
1003
|
+
: finding.kind === 'role'
|
|
1004
|
+
? 'As a "' + finding.who + '" who had just joined, I ' +
|
|
1005
|
+
listOf((finding.can || []).map((what) => {
|
|
1006
|
+
const n = (finding.changed || {})[what] || 1;
|
|
1007
|
+
return (what === 'change' ? 'changed ' : 'deleted ') + n + (n === 1 ? ' row' : ' rows');
|
|
1008
|
+
})) + ' in "' + finding.table + '" that I could not touch before joining. All of it was undone.'
|
|
1009
|
+
: finding.kind === 'bucket'
|
|
1010
|
+
? 'I read that "' + finding.bucket + '" is public and that ' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
|
|
1011
|
+
' says who may read it. I did not open any file.'
|
|
891
1012
|
: finding.kind === 'duplicated'
|
|
892
1013
|
? 'I created ' + finding.copies + ' rows in "' + finding.table + '" holding the same ' +
|
|
893
1014
|
finding.column + '.'
|
|
@@ -992,7 +1113,7 @@ function describeAll(findings) {
|
|
|
992
1113
|
// Writes above reads: a table somebody emptied is worse than one they read.
|
|
993
1114
|
// A looping rule sits with the reads: it breaks the app for everyone it
|
|
994
1115
|
// fails for, which matters more than a duplicate or a stray row.
|
|
995
|
-
const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, duplicated: 3, orphaned: 4, privileged: 5 };
|
|
1116
|
+
const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, duplicated: 3, orphaned: 4, role: 4.5, bucket: 4.7, privileged: 5 };
|
|
996
1117
|
const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
|
|
997
1118
|
return described.sort((a, b) => rank(a) - rank(b));
|
|
998
1119
|
}
|
package/intro.js
CHANGED
|
@@ -78,6 +78,9 @@ function consentLines(target) {
|
|
|
78
78
|
' - Look at the shape of your "' + target + '" tables: their names, their',
|
|
79
79
|
' columns, and the rules about who is allowed to see what.',
|
|
80
80
|
'',
|
|
81
|
+
' - Look at your file storage settings: which buckets are public, and',
|
|
82
|
+
' the rules about who may open their files. Never a file itself.',
|
|
83
|
+
'',
|
|
81
84
|
' - Make one new temporary space inside your database and rebuild that',
|
|
82
85
|
' same shape in it. Your real tables are not changed.',
|
|
83
86
|
'',
|
|
@@ -91,7 +94,8 @@ function consentLines(target) {
|
|
|
91
94
|
'What I will not do:',
|
|
92
95
|
'',
|
|
93
96
|
' - I do not read your real data. Not one customer, order or message.',
|
|
94
|
-
'
|
|
97
|
+
' Apart from the storage settings above, every row I read is a row I',
|
|
98
|
+
' put there myself a moment earlier.',
|
|
95
99
|
'',
|
|
96
100
|
' - I do not change your real tables. Only the temporary copy.',
|
|
97
101
|
'',
|
package/orphan.js
CHANGED
|
@@ -40,43 +40,11 @@ const attack = require('./attack.js');
|
|
|
40
40
|
// an audit row is the feature.
|
|
41
41
|
const KEEPS_HISTORY = /(^|_)(log|logs|audit|audits|event|events|history|archive|archives|snapshot|snapshots|activity|activities)(_|$)/i;
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (!match) continue;
|
|
49
|
-
const columns = match[1].split(',').map((name) => name.trim().split('"').join(''));
|
|
50
|
-
// A composite key is not something a single `<thing>_id` column points at.
|
|
51
|
-
return columns.length === 1 ? columns[0] : null;
|
|
52
|
-
}
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* The table a column named `<thing>_id` is pointing at, if there is one.
|
|
58
|
-
*
|
|
59
|
-
* The name has to match a table that is really here, and the types have to
|
|
60
|
-
* agree. Both, because `stripe_id` matches nothing and `org_id integer` does
|
|
61
|
-
* not point at an `orgs.id` that is a uuid.
|
|
62
|
-
*/
|
|
63
|
-
function parentFor(column, tables) {
|
|
64
|
-
const stem = /^(.+)_id$/i.exec(column.name);
|
|
65
|
-
if (!stem) return null;
|
|
66
|
-
const wanted = stem[1].toLowerCase();
|
|
67
|
-
const parent = (tables || []).find((table) => {
|
|
68
|
-
const name = table.name.toLowerCase();
|
|
69
|
-
return name === wanted || name === wanted + 's' || name === wanted + 'es';
|
|
70
|
-
});
|
|
71
|
-
if (!parent) return null;
|
|
72
|
-
|
|
73
|
-
const key = primaryKeyOf(parent);
|
|
74
|
-
if (!key) return null;
|
|
75
|
-
const keyColumn = (parent.columns || []).find((c) => c.name === key);
|
|
76
|
-
if (!keyColumn || keyColumn.type !== column.type) return null;
|
|
77
|
-
|
|
78
|
-
return { table: parent, keyColumn: key, type: keyColumn.type };
|
|
79
|
-
}
|
|
43
|
+
// Which table a `<thing>_id` column points at is decided in attack.js, because
|
|
44
|
+
// the seeder needs the same answer: a row has to point at a parent that is
|
|
45
|
+
// really there, whether or not a key says so.
|
|
46
|
+
const primaryKeyOf = attack.primaryKeyOf;
|
|
47
|
+
const parentFor = attack.parentFor;
|
|
80
48
|
|
|
81
49
|
/** Is this column already held down by a foreign key? */
|
|
82
50
|
function alreadyTied(table, columnName) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kryptheon-night",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.26",
|
|
4
4
|
"description": "Attacks a copy of your Supabase database and tells you in plain English what got in.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"node": ">=18.0.0"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
-
"check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node recursion.check.js && node guarded.check.js && node nightly.check.js && node words.check.js && node cli.check.js && node loop.check.js",
|
|
34
|
+
"check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node recursion.check.js && node guarded.check.js && node nightly.check.js && node words.check.js && node helpers.check.js && node deeploop.check.js && node teams.check.js && node roles.check.js && node buckets.check.js && node cli.check.js && node loop.check.js",
|
|
35
35
|
"check:dry": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node recheck.check.js && node outside.check.js",
|
|
36
36
|
"check:package": "node package.check.js",
|
|
37
37
|
"check:store": "node store.check.js",
|
|
@@ -57,6 +57,9 @@
|
|
|
57
57
|
"check:guarded": "node guarded.check.js",
|
|
58
58
|
"check:nightly": "node nightly.check.js",
|
|
59
59
|
"check:words": "node words.check.js",
|
|
60
|
+
"check:helpers": "node helpers.check.js",
|
|
61
|
+
"check:deeploop": "node deeploop.check.js",
|
|
62
|
+
"check:teams": "node teams.check.js",
|
|
60
63
|
"check:cli": "node cli.check.js",
|
|
61
64
|
"check:writes": "node tamper.check.js",
|
|
62
65
|
"check:orphans": "node orphans.check.js",
|
|
@@ -68,7 +71,9 @@
|
|
|
68
71
|
"check:twin": "node twin.check.js",
|
|
69
72
|
"prepublishOnly": "node package.check.js",
|
|
70
73
|
"check:engines": "node engines.check.js",
|
|
71
|
-
"check:installer": "node installer.check.js"
|
|
74
|
+
"check:installer": "node installer.check.js",
|
|
75
|
+
"check:roles": "node roles.check.js",
|
|
76
|
+
"check:buckets": "node buckets.check.js"
|
|
72
77
|
},
|
|
73
78
|
"keywords": [
|
|
74
79
|
"supabase",
|
package/recheck.js
CHANGED
|
@@ -172,6 +172,8 @@ function whatOf(item) {
|
|
|
172
172
|
if (item.kind === 'duplicated') return 'the same ' + (item.column || 'value') + ' twice';
|
|
173
173
|
if (item.kind === 'orphaned') return 'rows left pointing at nothing';
|
|
174
174
|
if (item.kind === 'privileged') return 'a function anyone can call';
|
|
175
|
+
if (item.kind === 'role') return 'changed by a "' + (item.who || 'viewer') + '"';
|
|
176
|
+
if (item.kind === 'bucket') return 'a public storage bucket';
|
|
175
177
|
if (item.kind === 'recursive') return 'a rule that refers to itself';
|
|
176
178
|
return item.kind;
|
|
177
179
|
}
|
|
@@ -196,6 +198,8 @@ function describe(result) {
|
|
|
196
198
|
// was seen, not as an attack that was refused. Found on a fix test.
|
|
197
199
|
const how = item.kind === 'privileged'
|
|
198
200
|
? 'I looked again, and a visitor with no account can no longer call it.'
|
|
201
|
+
: item.kind === 'bucket'
|
|
202
|
+
? 'I looked again, and the bucket is no longer public.'
|
|
199
203
|
: item.kind === 'recursive'
|
|
200
204
|
? 'I read it again, and its rules answered instead of stopping with an error.'
|
|
201
205
|
: 'I ran the same attack again and it was refused.';
|
package/scan.js
CHANGED
|
@@ -128,7 +128,7 @@ function suggest(schemas) {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
|
-
* The engine, as
|
|
131
|
+
* The engine, as seven operations, so the scan does not know which one it has.
|
|
132
132
|
*
|
|
133
133
|
* `scan.js` named `schema.js`, `attack.js`, `tamper.js` and `orphan.js`
|
|
134
134
|
* directly, which meant the SQL engine could only ever be reached by the twin
|
|
@@ -153,6 +153,7 @@ function nodeEngine() {
|
|
|
153
153
|
tamper: (client, into, tables, seeded, policies) =>
|
|
154
154
|
tamper.tamper(client, into, tables, seeded, policies),
|
|
155
155
|
orphan: (client, into, tables, seeded) => orphan.orphan(client, into, tables, seeded),
|
|
156
|
+
teammate: (client, into, tables, seeded) => tamper.teammate(client, into, tables, seeded),
|
|
156
157
|
};
|
|
157
158
|
}
|
|
158
159
|
|
|
@@ -302,6 +303,14 @@ async function scan(client, sourceSchema, options) {
|
|
|
302
303
|
notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
|
|
303
304
|
}
|
|
304
305
|
|
|
306
|
+
// What the least trusted member of a team can change there. Rolled back
|
|
307
|
+
// like the writes above, and the joining with them.
|
|
308
|
+
const ranked = await engine.teammate(client, copyName, theirs, sown.seeded);
|
|
309
|
+
for (const key of ranked.completed) attempted.push(key);
|
|
310
|
+
for (const stuck of ranked.blocked) {
|
|
311
|
+
notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
|
|
312
|
+
}
|
|
313
|
+
|
|
305
314
|
// Can a half-finished write survive? Rolled back like the writes above.
|
|
306
315
|
say(' Looking for rows that could point at nothing ...');
|
|
307
316
|
const stranded = await engine.orphan(client, copyName, theirs, sown.seeded);
|
|
@@ -320,16 +329,24 @@ async function scan(client, sourceSchema, options) {
|
|
|
320
329
|
// app is broken for the people using it. One finding per table at fault,
|
|
321
330
|
// named from Postgres's own message, however many reads ran into it.
|
|
322
331
|
const loops = new Map();
|
|
332
|
+
// A loop that named no table ("stack depth limit exceeded") is put on the
|
|
333
|
+
// tables whose own rule calls a helper that reads that same table; if
|
|
334
|
+
// none can be found, on the table that was being read.
|
|
335
|
+
const culprits = loopingTables(copyPlan);
|
|
323
336
|
const loopedInto = (relation, table, who) => {
|
|
324
|
-
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
337
|
+
const deep = !relation;
|
|
338
|
+
for (const at of relation ? [relation] : culprits.length ? culprits : [table]) {
|
|
339
|
+
if (!loops.has(at)) loops.set(at, { kind: 'recursive', table: at, reads: [], callers: [], columns: [], deep: false });
|
|
340
|
+
const entry = loops.get(at);
|
|
341
|
+
if (deep) entry.deep = true;
|
|
342
|
+
if (!entry.reads.includes(table)) entry.reads.push(table);
|
|
343
|
+
if (!entry.callers.includes(who)) entry.callers.push(who);
|
|
344
|
+
}
|
|
328
345
|
};
|
|
329
346
|
for (const hit of impersonation.looped || []) loopedInto(hit.relation, hit.table, hit.who);
|
|
330
347
|
for (const stuck of writes.blocked) {
|
|
331
348
|
const relation = attack.recursionIn(stuck.why);
|
|
332
|
-
if (relation) loopedInto(relation, stuck.table, /^as anyone:/.test(stuck.why) ? 'anyone' : 'signed-in');
|
|
349
|
+
if (relation || attack.tooDeep(stuck.why)) loopedInto(relation || null, stuck.table, /^as anyone:/.test(stuck.why) ? 'anyone' : 'signed-in');
|
|
333
350
|
}
|
|
334
351
|
const recursive = Array.from(loops.values());
|
|
335
352
|
|
|
@@ -387,6 +404,13 @@ async function scan(client, sourceSchema, options) {
|
|
|
387
404
|
for (const fn of plan.anonFunctions || []) attempted.push('privileged:' + fn.name);
|
|
388
405
|
const privileged = privilegedOf(plan);
|
|
389
406
|
|
|
407
|
+
// Storage buckets switched to public that also have a rule saying who may
|
|
408
|
+
// read them. Read from the bucket settings and the rules, never a file.
|
|
409
|
+
// Every bucket read counts as examined, so one made private comes back
|
|
410
|
+
// from a re-check as fixed.
|
|
411
|
+
for (const bucket of plan.buckets || []) attempted.push('bucket:' + bucket.name);
|
|
412
|
+
const buckets = bucketsOf(plan);
|
|
413
|
+
|
|
390
414
|
// Ties read from the original schema: the copy points auth.users keys at
|
|
391
415
|
// a stand-in, and would call every table untied. Owners read from the
|
|
392
416
|
// copy, the way seeding did.
|
|
@@ -398,7 +422,7 @@ async function scan(client, sourceSchema, options) {
|
|
|
398
422
|
notChecked: notChecked,
|
|
399
423
|
attempted: attempted,
|
|
400
424
|
findings: finding.describeAll(
|
|
401
|
-
impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged, recursive).map(tagged),
|
|
425
|
+
impersonation.findings.concat(writes.findings, ranked.findings, stranded.findings, raced, privileged, buckets, recursive).map(tagged),
|
|
402
426
|
),
|
|
403
427
|
};
|
|
404
428
|
} finally {
|
|
@@ -422,6 +446,36 @@ function exitCodeFor(result) {
|
|
|
422
446
|
return (result.findings || []).some((f) => f.status !== 'verification required') ? 1 : 0;
|
|
423
447
|
}
|
|
424
448
|
|
|
449
|
+
/**
|
|
450
|
+
* The tables whose own rule reaches back into the same table through the
|
|
451
|
+
* helpers it calls - directly or through helpers those call. Such a rule has
|
|
452
|
+
* to be evaluated to be evaluated, which is how "stack depth limit exceeded"
|
|
453
|
+
* happens; the message itself does not say which table.
|
|
454
|
+
*/
|
|
455
|
+
function loopingTables(plan) {
|
|
456
|
+
const escape = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
457
|
+
const calls = (text, name) => new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?\\s*\\(').test(String(text || ''));
|
|
458
|
+
const mentions = (text, name) => new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?([^A-Za-z0-9_$]|$)').test(String(text || ''));
|
|
459
|
+
const fns = (plan && plan.functions) || [];
|
|
460
|
+
const reached = (text) => {
|
|
461
|
+
const found = new Set(fns.filter((f) => calls(text, f.name)).map((f) => f.name));
|
|
462
|
+
let grew = true;
|
|
463
|
+
while (grew) {
|
|
464
|
+
grew = false;
|
|
465
|
+
for (const f of fns) {
|
|
466
|
+
if (found.has(f.name)) continue;
|
|
467
|
+
if (fns.some((g) => found.has(g.name) && calls(g.src, f.name))) { found.add(f.name); grew = true; }
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return fns.filter((f) => found.has(f.name));
|
|
471
|
+
};
|
|
472
|
+
return ((plan && plan.tables) || []).map((t) => t.name).filter((name) => {
|
|
473
|
+
const rules = ((plan && plan.policies) || []).filter((p) => p.table_name === name)
|
|
474
|
+
.map((p) => String(p.qual || '') + ' ' + String(p.with_check || '')).join(' ');
|
|
475
|
+
return rules.trim() && reached(rules).some((f) => mentions(f.src, name));
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
425
479
|
/**
|
|
426
480
|
* Functions an anonymous visitor can call that run past the rules. Read from
|
|
427
481
|
* the shape, not attacked. Quiet: a yes/no about the caller, and a function
|
|
@@ -439,6 +493,37 @@ function privilegedOf(plan) {
|
|
|
439
493
|
}));
|
|
440
494
|
}
|
|
441
495
|
|
|
496
|
+
/**
|
|
497
|
+
* A public bucket whose read rule says it was meant to be private.
|
|
498
|
+
*
|
|
499
|
+
* Found on two blind tests (OrbitDesk, AtlasPay): a bucket of exports and one
|
|
500
|
+
* of settlement evidence were switched to public. A public bucket hands any
|
|
501
|
+
* file to whoever has its link, and the rules on storage.objects are not asked.
|
|
502
|
+
* A rule that only lets in some people is how the app says the files are not
|
|
503
|
+
* for everyone - so a public bucket with one is worth a look. A rule that lets
|
|
504
|
+
* in everyone anyway (anon or public, and nothing but the bucket's name) says
|
|
505
|
+
* the opposite, and is not reported.
|
|
506
|
+
*/
|
|
507
|
+
function bucketsOf(plan) {
|
|
508
|
+
const escape = (x) => String(x).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
509
|
+
const narrows = (rule, id) => {
|
|
510
|
+
const roles = schema.roleList(rule.roles);
|
|
511
|
+
const toEveryone = roles.includes('anon') || roles.includes('public');
|
|
512
|
+
const rest = String(rule.qual || '')
|
|
513
|
+
.replace(new RegExp("bucket_id\\s*=\\s*'" + escape(id) + "'(::text)?", 'gi'), '')
|
|
514
|
+
.replace(new RegExp("'" + escape(id) + "'(::text)?\\s*=\\s*bucket_id", 'gi'), '')
|
|
515
|
+
.replace(/\btrue\b|\band\b|[()\s]/gi, '');
|
|
516
|
+
return !toEveryone || rest !== '';
|
|
517
|
+
};
|
|
518
|
+
return ((plan && plan.buckets) || []).filter((b) => b.public).map((b) => ({
|
|
519
|
+
kind: 'bucket',
|
|
520
|
+
table: b.name,
|
|
521
|
+
bucket: b.id,
|
|
522
|
+
rules: (b.readRules || []).filter((rule) => narrows(rule, b.id)).map((rule) => rule.name),
|
|
523
|
+
columns: [],
|
|
524
|
+
})).filter((f) => f.rules.length);
|
|
525
|
+
}
|
|
526
|
+
|
|
442
527
|
/**
|
|
443
528
|
* What each finding needs to know about its table for its fix to be right:
|
|
444
529
|
* whether the app signs people in through Supabase, whether a row says whose
|
|
@@ -521,8 +606,15 @@ function nightlyRecord(latest, readAt, plan) {
|
|
|
521
606
|
// functions anyone can call - read now, when this is asked, because the
|
|
522
607
|
// engine inside the database does not look at them.
|
|
523
608
|
const tag = plan ? taggerFor(plan, plan.tables) : (f) => f;
|
|
524
|
-
|
|
525
|
-
|
|
609
|
+
// A loop the engine could not name ("stack depth limit exceeded") is put
|
|
610
|
+
// on the tables whose own rule reads back into them, read from the shape.
|
|
611
|
+
const loops = plan ? loopingTables(plan) : [];
|
|
612
|
+
const night = [];
|
|
613
|
+
for (const f of run.findings || []) {
|
|
614
|
+
const at = f.kind === 'recursive' && !f.table ? (loops.length ? loops : ['one of your tables']) : [f.table];
|
|
615
|
+
for (const table of at) night.push(tag(Object.assign({}, f, { table: table, fromNight: true })));
|
|
616
|
+
}
|
|
617
|
+
const now = plan ? privilegedOf(plan).concat(bucketsOf(plan)).map(tag) : [];
|
|
526
618
|
return Object.assign(record, {
|
|
527
619
|
ranAt: at(run.ran_at),
|
|
528
620
|
source: run.source,
|
|
@@ -692,8 +784,14 @@ function report(result) {
|
|
|
692
784
|
line(' ' + confirmed.length + (confirmed.length === 1 ? ' problem' : ' problems') + ' found.');
|
|
693
785
|
}
|
|
694
786
|
if (toVerify.length) {
|
|
695
|
-
|
|
696
|
-
|
|
787
|
+
// A viewer who can edit was really tried; a function or a bucket was only
|
|
788
|
+
// read. Either way it is the owner's call, and the line says which.
|
|
789
|
+
const tried = toVerify.some((f) => f.kind === 'role');
|
|
790
|
+
const spotted = toVerify.some((f) => f.kind !== 'role');
|
|
791
|
+
const it = toVerify.length === 1 ? 'it' : 'them';
|
|
792
|
+
line(' ' + toVerify.length + ' thing' + (toVerify.length === 1 ? '' : 's') + ' to check - ' +
|
|
793
|
+
(spotted && !tried ? 'I did not attack ' + it + ', only spotted the risk.'
|
|
794
|
+
: 'whether ' + (toVerify.length === 1 ? 'it is a problem' : 'each is a problem') + ' is your decision.'));
|
|
697
795
|
}
|
|
698
796
|
|
|
699
797
|
for (const item of result.findings) {
|
|
@@ -842,6 +940,8 @@ module.exports = {
|
|
|
842
940
|
scheduleTimes: scheduleTimes,
|
|
843
941
|
notCheckedFromNight: notCheckedFromNight,
|
|
844
942
|
privilegedOf: privilegedOf,
|
|
943
|
+
bucketsOf: bucketsOf,
|
|
944
|
+
loopingTables: loopingTables,
|
|
845
945
|
taggerFor: taggerFor,
|
|
846
946
|
saveNightly: saveNightly,
|
|
847
947
|
sweepOldCopies: sweepOldCopies,
|