kryptheon-night 0.1.25 → 0.1.27
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 +63 -2
- package/bin/kryptheon-night.js +3 -1
- package/engine.sql +571 -3
- package/finding.js +163 -3
- package/intro.js +5 -1
- package/package.json +5 -3
- package/recheck.js +138 -13
- package/scan.js +75 -6
- package/schema.js +59 -0
- package/sqlengine.js +12 -1
- package/tamper.js +334 -1
package/finding.js
CHANGED
|
@@ -88,6 +88,17 @@ 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
|
+
/** The same, with its article: a workspace, an organization. */
|
|
93
|
+
function aOne(table) {
|
|
94
|
+
const one = oneOf(table);
|
|
95
|
+
return (/^[aeiou]/i.test(one) ? 'an ' : 'a ') + one;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function oneOf(table) {
|
|
99
|
+
return String(table || 'team').replace(/ies$/, 'y').replace(/s$/, '');
|
|
100
|
+
}
|
|
101
|
+
|
|
91
102
|
/* --------------------------------------------------------------------------
|
|
92
103
|
How bad it is.
|
|
93
104
|
-------------------------------------------------------------------------- */
|
|
@@ -105,6 +116,11 @@ function severityOf(finding, contents) {
|
|
|
105
116
|
// because the app is broken for whoever it fails for, but not a break-in.
|
|
106
117
|
return 'HIGH';
|
|
107
118
|
}
|
|
119
|
+
if (finding.kind === 'role' || finding.kind === 'teamread' || finding.kind === 'bucket') {
|
|
120
|
+
// Something to check, never a confirmed break: whether a viewer may edit,
|
|
121
|
+
// or a bucket may be public, is the owner's decision. So never CRITICAL.
|
|
122
|
+
return 'HIGH';
|
|
123
|
+
}
|
|
108
124
|
if (finding.kind === 'privileged') {
|
|
109
125
|
// Not proven, so never CRITICAL: this is a reach that exists, not a break
|
|
110
126
|
// that was demonstrated. A function anon can call that also writes is worth
|
|
@@ -332,6 +348,19 @@ function headlineFor(finding) {
|
|
|
332
348
|
if (finding.kind === 'privileged') {
|
|
333
349
|
return 'Anyone on the internet can call your ' + finding.fn + ' function, and it runs with full rights.';
|
|
334
350
|
}
|
|
351
|
+
if (finding.kind === 'role') {
|
|
352
|
+
const order = ['delete', 'change', 'add'];
|
|
353
|
+
const does = order.filter((what) => (finding.can || []).includes(what)).map((what) => WRITE_WORDS[what]);
|
|
354
|
+
return 'A "' + finding.who + '" in ' + aOne(finding.parent) + ' can ' + listOf(does) + ' your ' +
|
|
355
|
+
finding.table + ' table.';
|
|
356
|
+
}
|
|
357
|
+
if (finding.kind === 'teamread') {
|
|
358
|
+
return 'A "' + finding.who + '" in ' + aOne(finding.parent) + ' can read your ' + finding.table +
|
|
359
|
+
' table, which holds ' + listOf(finding.secrets || []) + '.';
|
|
360
|
+
}
|
|
361
|
+
if (finding.kind === 'bucket') {
|
|
362
|
+
return 'Your ' + finding.table + ' storage bucket is public, but a rule says only some people may read it.';
|
|
363
|
+
}
|
|
335
364
|
if (finding.kind === 'exposed') {
|
|
336
365
|
return 'Your ' + finding.table + ' ' + (finding.isView ? 'view' : 'table') + ' can be read by anyone.';
|
|
337
366
|
}
|
|
@@ -415,6 +444,53 @@ function bodyFor(finding, contents) {
|
|
|
415
444
|
'kept, and your live app was never touched.';
|
|
416
445
|
}
|
|
417
446
|
|
|
447
|
+
if (finding.kind === 'role') {
|
|
448
|
+
// Said as what was done, and then whose decision it is: a viewer who may
|
|
449
|
+
// edit is a hole in one app and the design of another.
|
|
450
|
+
const did = [];
|
|
451
|
+
const rows = (n) => n + (n === 1 ? ' row' : ' rows');
|
|
452
|
+
if ((finding.can || []).includes('add')) did.push('added ' + rows((finding.changed || {}).add || 1));
|
|
453
|
+
if ((finding.can || []).includes('change')) did.push('changed ' + rows((finding.changed || {}).change || 1));
|
|
454
|
+
if ((finding.can || []).includes('delete')) did.push('deleted ' + rows((finding.changed || {}).delete || 1));
|
|
455
|
+
const place = oneOf(finding.parent);
|
|
456
|
+
return (
|
|
457
|
+
'I put one of my fake people into the other one\'s ' + place + ' as a "' + finding.who +
|
|
458
|
+
'" - the lowest role in ' + finding.via + '.' + finding.roleColumn + ' - and, signed in as them, I ' +
|
|
459
|
+
listOf(did) + ' in ' + finding.table + ' for that ' + place + ', on a copy of your app. ' +
|
|
460
|
+
'Before joining, they could not. Every one of those was undone straight away. Whether a "' + finding.who +
|
|
461
|
+
'" may do this is your decision: if it is meant to, nothing needs fixing; if a "' + finding.who +
|
|
462
|
+
'" should only look, the rules on this table let them do more.'
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (finding.kind === 'teamread') {
|
|
467
|
+
// Said as what was read and by whom, and which roles below it could not:
|
|
468
|
+
// a dispatcher seeing a key may be the design, a viewer rarely is.
|
|
469
|
+
const place = oneOf(finding.parent);
|
|
470
|
+
const n = finding.readable || 1;
|
|
471
|
+
const below = (finding.below || []).map((r) => '"' + r + '"');
|
|
472
|
+
return (
|
|
473
|
+
'I put one of my fake people into the other one\'s ' + place + ' as a "' + finding.who + '" and, signed ' +
|
|
474
|
+
'in as them, read ' + n + (n === 1 ? ' row' : ' rows') + ' of ' + finding.table + ' for that ' + place +
|
|
475
|
+
', on a copy of your app - including ' + listOf(finding.secrets || []) + '. Before joining, they could not.' +
|
|
476
|
+
(below.length ? ' As ' + listOf(below).replace(/ and /, ' or ') + ' they could not read it either.' : '') +
|
|
477
|
+
' Nothing was changed. Whether a "' + finding.who + '" should see these is your decision: if not, a read ' +
|
|
478
|
+
'rule on this table lets them.'
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (finding.kind === 'bucket') {
|
|
483
|
+
// Said as what was read, never as a file taken: no file was opened.
|
|
484
|
+
return (
|
|
485
|
+
'In Supabase Storage the bucket "' + finding.bucket + '" is set to public, so anyone with a ' +
|
|
486
|
+
'file\'s link can download it without logging in - the rules on storage.objects are not asked. ' +
|
|
487
|
+
'You also wrote ' + ((finding.rules || []).length === 1 ? 'a rule' : 'rules') + ' saying who may read it (' +
|
|
488
|
+
listOf(finding.rules || []) + '), so it was probably meant to be private. I only read the ' +
|
|
489
|
+
'bucket\'s setting and its rules; I did not open or list any file. Whether it should be public ' +
|
|
490
|
+
'is yours to say.'
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
418
494
|
if (finding.kind === 'privileged') {
|
|
419
495
|
// Said as what is true, not as what was done - nothing was executed. The
|
|
420
496
|
// reach is real; whether it is a mistake is the person's to say, so the
|
|
@@ -694,6 +770,69 @@ function fixPromptFor(finding) {
|
|
|
694
770
|
'Then sign in as an ordinary user and open the pages that use these tables, and run this ' +
|
|
695
771
|
'check again - the tables it could not test will be tested this time.',
|
|
696
772
|
]
|
|
773
|
+
: finding.kind === 'role'
|
|
774
|
+
? [
|
|
775
|
+
'My app may have a permissions problem - please check it rather than assume it.',
|
|
776
|
+
'',
|
|
777
|
+
'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
|
|
778
|
+
finding.roleColumn + '" is "' + finding.who + '" can ' +
|
|
779
|
+
listOf((finding.can || []).map((what) => ({ add: 'add', change: 'change', delete: 'delete' })[what])) +
|
|
780
|
+
' rows of that ' + oneOf(finding.parent) + ' in the "' + finding.table + '" table. This was proved ' +
|
|
781
|
+
'on a copy of the database: a "' + finding.who + '" was added, and the writes went through.',
|
|
782
|
+
'',
|
|
783
|
+
'First tell me: should a "' + finding.who + '" be able to do that? If yes, change nothing.',
|
|
784
|
+
'',
|
|
785
|
+
'If not, make the rules on "' + finding.table + '" that cover UPDATE, DELETE, INSERT or ALL check ' +
|
|
786
|
+
'the member\'s role as well as membership - for example with a helper that reads "' + finding.via +
|
|
787
|
+
'" for auth.uid() and the roles allowed to edit. Postgres lets a write through if ANY permissive ' +
|
|
788
|
+
'policy allows it, so a strict rule beside a loose one does nothing: list every such policy on "' +
|
|
789
|
+
finding.table + '" and narrow or drop the loose one. Keep the read rule as it is if a "' +
|
|
790
|
+
finding.who + '" should still see these rows.',
|
|
791
|
+
'',
|
|
792
|
+
'Then check every other table where members can write for the same thing.',
|
|
793
|
+
]
|
|
794
|
+
: finding.kind === 'teamread'
|
|
795
|
+
? [
|
|
796
|
+
'My app may have a permissions problem - please check it rather than assume it.',
|
|
797
|
+
'',
|
|
798
|
+
'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
|
|
799
|
+
finding.roleColumn + '" is "' + finding.who + '" can read the "' + finding.table + '" table of that ' +
|
|
800
|
+
oneOf(finding.parent) + ', including ' + listOf((finding.secrets || []).map((c) => '"' + c + '"')) +
|
|
801
|
+
'. This was proved on a copy of the database: a "' + finding.who + '" was added, and the rows came back.',
|
|
802
|
+
'',
|
|
803
|
+
'First tell me: should a "' + finding.who + '" see these? If yes, change nothing.',
|
|
804
|
+
'',
|
|
805
|
+
'If not, make the rules on "' + finding.table + '" that cover SELECT or ALL check the member\'s role as ' +
|
|
806
|
+
'well as membership. Postgres lets a read through if ANY permissive policy allows it, so a strict rule ' +
|
|
807
|
+
'beside a loose one does nothing: list every such policy on "' + finding.table + '" and narrow or drop ' +
|
|
808
|
+
'the loose one. If members need the other columns, a view or function that leaves the secret ones out ' +
|
|
809
|
+
'is often the better fix.',
|
|
810
|
+
'',
|
|
811
|
+
'Then check every other table that holds tokens, keys, secrets or payloads for the same thing.',
|
|
812
|
+
]
|
|
813
|
+
: finding.kind === 'bucket'
|
|
814
|
+
? [
|
|
815
|
+
'My app may have a storage problem - please check it rather than assume it.',
|
|
816
|
+
'',
|
|
817
|
+
'The Supabase Storage bucket "' + finding.bucket + '" is public. Anyone with a file\'s URL can ' +
|
|
818
|
+
'download it without logging in, and the storage.objects ' +
|
|
819
|
+
((finding.rules || []).length === 1 ? 'policy that names' : 'policies that name') + ' this bucket (' +
|
|
820
|
+
listOf((finding.rules || []).map((r) => '"' + r + '"')) + ') ' +
|
|
821
|
+
((finding.rules || []).length === 1 ? 'is' : 'are') + ' not checked for public URLs - so ' +
|
|
822
|
+
((finding.rules || []).length === 1 ? 'it protects' : 'they protect') + ' nothing while it is public.',
|
|
823
|
+
'',
|
|
824
|
+
'First tell me: is every file in this bucket meant to be downloadable by anyone? If yes, change nothing.',
|
|
825
|
+
'',
|
|
826
|
+
'If not, make the bucket private (in the Supabase dashboard: Storage, edit the bucket, turn Public ' +
|
|
827
|
+
'off - or UPDATE storage.buckets SET public = false WHERE id = \'' + finding.bucket + '\'). Then ' +
|
|
828
|
+
'change the app to fetch these files with signed URLs (createSignedUrl) instead of getPublicUrl, ' +
|
|
829
|
+
'or the links it shows will stop working.',
|
|
830
|
+
'',
|
|
831
|
+
'Keep the read rule: once the bucket is private it is what decides who gets a file. Check that it ' +
|
|
832
|
+
'only lets in the right people.',
|
|
833
|
+
'',
|
|
834
|
+
'Then look at every other bucket for the same thing.',
|
|
835
|
+
]
|
|
697
836
|
: finding.kind === 'privileged'
|
|
698
837
|
? [
|
|
699
838
|
'My app may have a security problem - please check it rather than assume it.',
|
|
@@ -863,6 +1002,10 @@ function fixPromptFor(finding) {
|
|
|
863
1002
|
.join('\n');
|
|
864
1003
|
}
|
|
865
1004
|
|
|
1005
|
+
// Found and said, but never claimed as a break: the owner decides. Counted
|
|
1006
|
+
// apart in the report, never towards the exit code or a clean re-check.
|
|
1007
|
+
const TO_CHECK = ['privileged', 'role', 'teamread', 'bucket'];
|
|
1008
|
+
|
|
866
1009
|
/** Everything the report needs about one thing the attack found. */
|
|
867
1010
|
function describe(finding) {
|
|
868
1011
|
const contents = readContents(finding.columns);
|
|
@@ -873,7 +1016,7 @@ function describe(finding) {
|
|
|
873
1016
|
// one the tool reasons about without executing, so it says so. The report
|
|
874
1017
|
// and the re-check both read this: a "verification required" finding is
|
|
875
1018
|
// never counted towards a clean re-check on its own.
|
|
876
|
-
status: finding.kind
|
|
1019
|
+
status: TO_CHECK.includes(finding.kind) ? 'verification required' : 'confirmed',
|
|
877
1020
|
table: finding.kind === 'privileged' ? finding.fn : finding.table,
|
|
878
1021
|
fn: finding.fn,
|
|
879
1022
|
args: finding.args,
|
|
@@ -892,14 +1035,31 @@ function describe(finding) {
|
|
|
892
1035
|
// For a privileged function there is no "why the door is open" the way a
|
|
893
1036
|
// table has one; the body already carries the whole explanation, so the
|
|
894
1037
|
// cause line would only repeat it. Left empty and skipped by the report.
|
|
895
|
-
cause: finding.kind
|
|
1038
|
+
cause: TO_CHECK.includes(finding.kind) ? '' : cause.long,
|
|
896
1039
|
who: finding.who,
|
|
897
1040
|
can: finding.can,
|
|
1041
|
+
via: finding.via,
|
|
1042
|
+
roleColumn: finding.roleColumn,
|
|
1043
|
+
readable: finding.readable,
|
|
1044
|
+
secrets: finding.secrets,
|
|
1045
|
+
bucket: finding.bucket,
|
|
898
1046
|
proof: finding.kind === 'recursive'
|
|
899
1047
|
? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
|
|
900
1048
|
loopError(finding) + '.'
|
|
901
1049
|
: finding.kind === 'privileged'
|
|
902
1050
|
? 'I did not call "' + finding.fn + '". This is a reach that exists in the grants, not a break I ran.'
|
|
1051
|
+
: finding.kind === 'role'
|
|
1052
|
+
? 'As a "' + finding.who + '" who had just joined, I ' +
|
|
1053
|
+
listOf((finding.can || []).map((what) => {
|
|
1054
|
+
const n = (finding.changed || {})[what] || 1;
|
|
1055
|
+
return ({ add: 'added ', change: 'changed ', delete: 'deleted ' })[what] + n + (n === 1 ? ' row' : ' rows');
|
|
1056
|
+
})) + ' in "' + finding.table + '" that I could not touch before joining. All of it was undone.'
|
|
1057
|
+
: finding.kind === 'teamread'
|
|
1058
|
+
? 'As a "' + finding.who + '" who had just joined, I read ' + (finding.readable || 1) +
|
|
1059
|
+
((finding.readable || 1) === 1 ? ' row' : ' rows') + ' of "' + finding.table + '" that I could not read before joining.'
|
|
1060
|
+
: finding.kind === 'bucket'
|
|
1061
|
+
? 'I read that "' + finding.bucket + '" is public and that ' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
|
|
1062
|
+
' says who may read it. I did not open any file.'
|
|
903
1063
|
: finding.kind === 'duplicated'
|
|
904
1064
|
? 'I created ' + finding.copies + ' rows in "' + finding.table + '" holding the same ' +
|
|
905
1065
|
finding.column + '.'
|
|
@@ -1004,7 +1164,7 @@ function describeAll(findings) {
|
|
|
1004
1164
|
// Writes above reads: a table somebody emptied is worse than one they read.
|
|
1005
1165
|
// A looping rule sits with the reads: it breaks the app for everyone it
|
|
1006
1166
|
// fails for, which matters more than a duplicate or a stray row.
|
|
1007
|
-
const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, duplicated: 3, orphaned: 4, privileged: 5 };
|
|
1167
|
+
const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, duplicated: 3, orphaned: 4, role: 4.5, teamread: 4.6, bucket: 4.7, privileged: 5 };
|
|
1008
1168
|
const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
|
|
1009
1169
|
return described.sort((a, b) => rank(a) - rank(b));
|
|
1010
1170
|
}
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kryptheon-night",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.27",
|
|
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 helpers.check.js && node deeploop.check.js && node teams.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",
|
|
@@ -71,7 +71,9 @@
|
|
|
71
71
|
"check:twin": "node twin.check.js",
|
|
72
72
|
"prepublishOnly": "node package.check.js",
|
|
73
73
|
"check:engines": "node engines.check.js",
|
|
74
|
-
"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"
|
|
75
77
|
},
|
|
76
78
|
"keywords": [
|
|
77
79
|
"supabase",
|
package/recheck.js
CHANGED
|
@@ -53,6 +53,53 @@ function proofsOf(list) {
|
|
|
53
53
|
return (list || []).reduce((all, item) => all.concat(item.members && item.members.length ? item.members : [item]), []);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/*
|
|
57
|
+
* Whether the earlier run was on this database at all.
|
|
58
|
+
*
|
|
59
|
+
* Found on a blind test (HelixOps): the repository's database was scanned,
|
|
60
|
+
* then the production one from the same folder, and the re-check reported the
|
|
61
|
+
* six differences as "NEW problems" and said "A fix can open something else".
|
|
62
|
+
* Nothing had been fixed; it was another database. A run now says where it
|
|
63
|
+
* was; one saved before that is judged by the tables it attacked.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
// Attacks named for a table, as opposed to a function or a bucket.
|
|
67
|
+
const ON_A_TABLE = /^(exposed|crossed|writable|recursive|orphaned|duplicated|role|teamread):/;
|
|
68
|
+
|
|
69
|
+
/** The tables a run looked at: as it said, or else as its attacks name them. */
|
|
70
|
+
function tablesOf(run, said) {
|
|
71
|
+
if (said) return run.where.tables;
|
|
72
|
+
return Array.from(new Set(((run && run.attempted) || []).filter((key) => ON_A_TABLE.test(String(key)))
|
|
73
|
+
.map((key) => String(key).split(':')[1])));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A database, said the way a person would recognise it. */
|
|
77
|
+
function placeOf(where) {
|
|
78
|
+
if (!where) return null;
|
|
79
|
+
return '"' + where.database + '"' + (where.host ? ' on ' + where.host + (where.port ? ':' + where.port : '') : '') +
|
|
80
|
+
(where.schema && where.schema !== 'public' ? ', schema "' + where.schema + '"' : '');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function elsewhereOf(before, after) {
|
|
84
|
+
const was = before && before.where;
|
|
85
|
+
const now = after && after.where;
|
|
86
|
+
const said = Boolean(was && now && Array.isArray(was.tables) && Array.isArray(now.tables));
|
|
87
|
+
const a = new Set(tablesOf(before, said));
|
|
88
|
+
const b = new Set(tablesOf(after, said));
|
|
89
|
+
const onlyBefore = Array.from(a).filter((t) => !b.has(t)).sort();
|
|
90
|
+
const onlyNow = Array.from(b).filter((t) => !a.has(t)).sort();
|
|
91
|
+
const union = new Set(Array.from(a).concat(Array.from(b))).size;
|
|
92
|
+
const shared = union - onlyBefore.length - onlyNow.length;
|
|
93
|
+
const otherDatabase = Boolean(was && now) && (was.database !== now.database ||
|
|
94
|
+
(was.host || null) !== (now.host || null) || (was.port || null) !== (now.port || null) || was.schema !== now.schema);
|
|
95
|
+
// Fewer than half the tables in common: not the same app any more. Only
|
|
96
|
+
// said when both runs name tables: a run that names none says nothing.
|
|
97
|
+
const otherShape = a.size > 0 && b.size > 0 && shared * 2 < union;
|
|
98
|
+
if (!otherDatabase && !otherShape) return null;
|
|
99
|
+
return { otherDatabase: otherDatabase, otherShape: otherShape, was: placeOf(was), now: placeOf(now),
|
|
100
|
+
onlyBefore: onlyBefore, onlyNow: onlyNow };
|
|
101
|
+
}
|
|
102
|
+
|
|
56
103
|
function compare(before, after) {
|
|
57
104
|
const was = proofsOf(before && before.findings);
|
|
58
105
|
const now = proofsOf(after && after.findings);
|
|
@@ -120,9 +167,13 @@ function compare(before, after) {
|
|
|
120
167
|
|
|
121
168
|
const wasByKey = new Set(was.map(keyOf));
|
|
122
169
|
const newlyBroken = now.filter((item) => !wasByKey.has(keyOf(item)));
|
|
170
|
+
const elsewhere = elsewhereOf(before, after);
|
|
123
171
|
|
|
124
172
|
return {
|
|
125
173
|
stopped: null,
|
|
174
|
+
// Set when the earlier run was on another database, or on a very
|
|
175
|
+
// different set of tables: then this is a comparison, not a re-check.
|
|
176
|
+
elsewhere: elsewhere,
|
|
126
177
|
fixed: fixed,
|
|
127
178
|
stillOpen: stillOpen,
|
|
128
179
|
unverifiable: unverifiable,
|
|
@@ -138,7 +189,9 @@ function compare(before, after) {
|
|
|
138
189
|
// unverifiable, so "the other two are empty" already means "all of them
|
|
139
190
|
// were fixed" - stating that a third time as a count only made each guard
|
|
140
191
|
// able to cover for the others, which is how a broken guard stays hidden.
|
|
192
|
+
// And never from two different databases: nothing was fixed between them.
|
|
141
193
|
allClear:
|
|
194
|
+
!elsewhere &&
|
|
142
195
|
!stillOpen.length &&
|
|
143
196
|
!unverifiable.length &&
|
|
144
197
|
!newlyBroken.length &&
|
|
@@ -172,10 +225,59 @@ function whatOf(item) {
|
|
|
172
225
|
if (item.kind === 'duplicated') return 'the same ' + (item.column || 'value') + ' twice';
|
|
173
226
|
if (item.kind === 'orphaned') return 'rows left pointing at nothing';
|
|
174
227
|
if (item.kind === 'privileged') return 'a function anyone can call';
|
|
228
|
+
if (item.kind === 'role') return 'changed by a "' + (item.who || 'viewer') + '"';
|
|
229
|
+
if (item.kind === 'teamread') return 'read by a "' + (item.who || 'viewer') + '"';
|
|
230
|
+
if (item.kind === 'bucket') return 'a public storage bucket';
|
|
175
231
|
if (item.kind === 'recursive') return 'a rule that refers to itself';
|
|
176
232
|
return item.kind;
|
|
177
233
|
}
|
|
178
234
|
|
|
235
|
+
/** Wraps one paragraph for the screen, indented the way these lines are. */
|
|
236
|
+
function wrapped(text) {
|
|
237
|
+
const out = [];
|
|
238
|
+
let current = '';
|
|
239
|
+
for (const word of String(text).split(/\s+/)) {
|
|
240
|
+
if ((current + ' ' + word).trim().length > 70) {
|
|
241
|
+
out.push(' ' + current.trim());
|
|
242
|
+
current = word;
|
|
243
|
+
} else {
|
|
244
|
+
current = (current + ' ' + word).trim();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (current) out.push(' ' + current.trim());
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Up to eight names, and how many more. */
|
|
252
|
+
function someOf(names) {
|
|
253
|
+
const shown = names.slice(0, 8).join(', ');
|
|
254
|
+
return names.length > 8 ? shown + ' and ' + (names.length - 8) + ' more' : shown;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Said first when the earlier run was somewhere else: which two databases,
|
|
259
|
+
* and how their tables differ - so nothing after it reads as a fix.
|
|
260
|
+
*/
|
|
261
|
+
function elsewhereLines(elsewhere) {
|
|
262
|
+
const lines = [];
|
|
263
|
+
const tables = (n) => n + (n === 1 ? ' table' : ' tables');
|
|
264
|
+
lines.push(' This is not a re-check of the same database.');
|
|
265
|
+
lines.push('');
|
|
266
|
+
const where = elsewhere.otherDatabase
|
|
267
|
+
? 'The earlier run kept for this folder was on ' + elsewhere.was + ', and this one is on ' + elsewhere.now + '.'
|
|
268
|
+
: 'The earlier run kept for this folder looked at a very different set of tables.';
|
|
269
|
+
wrapped(where + ' So what follows compares two databases: nothing in it means a fix worked, ' +
|
|
270
|
+
'and nothing in it means a fix broke something.').forEach((l) => lines.push(l));
|
|
271
|
+
lines.push('');
|
|
272
|
+
const shape = [];
|
|
273
|
+
if (elsewhere.onlyBefore.length) shape.push(tables(elsewhere.onlyBefore.length) + ' only in the earlier one (' + someOf(elsewhere.onlyBefore) + ')');
|
|
274
|
+
if (elsewhere.onlyNow.length) shape.push(tables(elsewhere.onlyNow.length) + ' only in this one (' + someOf(elsewhere.onlyNow) + ')');
|
|
275
|
+
wrapped(shape.length ? 'How their tables differ: ' + shape.join('; ') + '.' : 'Both have the same tables.')
|
|
276
|
+
.forEach((l) => lines.push(l));
|
|
277
|
+
lines.push('');
|
|
278
|
+
return lines;
|
|
279
|
+
}
|
|
280
|
+
|
|
179
281
|
/** What the re-check says, in the order it matters. */
|
|
180
282
|
function describe(result) {
|
|
181
283
|
const lines = [''];
|
|
@@ -188,24 +290,34 @@ function describe(result) {
|
|
|
188
290
|
return lines;
|
|
189
291
|
}
|
|
190
292
|
|
|
293
|
+
const elsewhere = result.elsewhere;
|
|
294
|
+
if (elsewhere) elsewhereLines(elsewhere).forEach((l) => lines.push(l));
|
|
295
|
+
|
|
191
296
|
if (result.fixed.length) {
|
|
192
|
-
|
|
297
|
+
const n = result.fixed.length;
|
|
298
|
+
lines.push(elsewhere
|
|
299
|
+
? ' ' + n + ' found in the earlier run and not here:'
|
|
300
|
+
: ' ' + n + (n === 1 ? ' problem is' : ' problems are') + ' fixed:');
|
|
193
301
|
lines.push('');
|
|
194
302
|
for (const item of result.fixed) {
|
|
195
303
|
// A function is never called, only read - so its fix is said as what
|
|
196
304
|
// was seen, not as an attack that was refused. Found on a fix test.
|
|
197
305
|
const how = item.kind === 'privileged'
|
|
198
|
-
? 'I looked
|
|
306
|
+
? (elsewhere ? 'I looked here, and a visitor with no account cannot call it.'
|
|
307
|
+
: 'I looked again, and a visitor with no account can no longer call it.')
|
|
308
|
+
: item.kind === 'bucket'
|
|
309
|
+
? (elsewhere ? 'I looked here, and the bucket is not public.' : 'I looked again, and the bucket is no longer public.')
|
|
199
310
|
: item.kind === 'recursive'
|
|
200
|
-
? 'I read it
|
|
201
|
-
|
|
311
|
+
? (elsewhere ? 'I read it here, and its rules answered.'
|
|
312
|
+
: 'I read it again, and its rules answered instead of stopping with an error.')
|
|
313
|
+
: (elsewhere ? 'I ran the same attack here and it was refused.' : 'I ran the same attack again and it was refused.');
|
|
202
314
|
lines.push(' ' + item.table + ' (' + whatOf(item) + ') - ' + how);
|
|
203
315
|
}
|
|
204
316
|
lines.push('');
|
|
205
317
|
}
|
|
206
318
|
|
|
207
319
|
if (result.stillOpen.length) {
|
|
208
|
-
lines.push(' ' + result.stillOpen.length + ' still open:');
|
|
320
|
+
lines.push(' ' + result.stillOpen.length + (elsewhere ? ' found in both:' : ' still open:'));
|
|
209
321
|
lines.push('');
|
|
210
322
|
for (const item of result.stillOpen) {
|
|
211
323
|
lines.push(' ' + item.table + ' - ' + item.headline);
|
|
@@ -216,32 +328,44 @@ function describe(result) {
|
|
|
216
328
|
// Deliberately not under "fixed", and deliberately not silent. This is the
|
|
217
329
|
// one a person would otherwise read as good news.
|
|
218
330
|
if (result.unverifiable.length) {
|
|
219
|
-
lines.push(' ' + result.unverifiable.length + ' I could NOT confirm:');
|
|
331
|
+
lines.push(' ' + result.unverifiable.length + (elsewhere ? ' I could NOT compare:' : ' I could NOT confirm:'));
|
|
220
332
|
lines.push('');
|
|
221
333
|
for (const item of result.unverifiable) {
|
|
222
334
|
lines.push(' ' + item.table + ' (' + whatOf(item) + ') - ' + item.why);
|
|
223
335
|
}
|
|
224
336
|
lines.push('');
|
|
225
|
-
|
|
226
|
-
|
|
337
|
+
if (elsewhere) {
|
|
338
|
+
lines.push(' These were found in the earlier run, and the same attack did not run');
|
|
339
|
+
lines.push(' here, so whether they are here too is unknown.');
|
|
340
|
+
} else {
|
|
341
|
+
lines.push(' These are not fixed and not broken - they are unknown. The problem');
|
|
342
|
+
lines.push(' stopped showing up, but not because the attack was refused.');
|
|
343
|
+
}
|
|
227
344
|
lines.push('');
|
|
228
345
|
}
|
|
229
346
|
|
|
230
347
|
if (result.newlyBroken.length) {
|
|
231
|
-
|
|
232
|
-
|
|
348
|
+
const n = result.newlyBroken.length;
|
|
349
|
+
lines.push(elsewhere
|
|
350
|
+
? ' ' + n + ' found here and not in the earlier run:'
|
|
351
|
+
: ' ' + n + ' NEW ' + (n === 1 ? 'problem' : 'problems') + ' that were not there before:');
|
|
233
352
|
lines.push('');
|
|
234
353
|
for (const item of result.newlyBroken) {
|
|
235
354
|
lines.push(' ' + item.table + ' - ' + item.headline);
|
|
236
355
|
}
|
|
237
356
|
lines.push('');
|
|
238
|
-
|
|
239
|
-
|
|
357
|
+
if (elsewhere) {
|
|
358
|
+
lines.push(' These are differences between the two databases, not something a fix');
|
|
359
|
+
lines.push(' opened.');
|
|
360
|
+
} else {
|
|
361
|
+
lines.push(' A fix can open something else. This is why the re-check looks at the');
|
|
362
|
+
lines.push(' whole app again and not only at what it was asked about.');
|
|
363
|
+
}
|
|
240
364
|
lines.push('');
|
|
241
365
|
}
|
|
242
366
|
|
|
243
367
|
if (!result.fixed.length && !result.stillOpen.length && !result.unverifiable.length && !result.newlyBroken.length) {
|
|
244
|
-
lines.push(' Nothing to re-check - there was nothing open.');
|
|
368
|
+
lines.push(elsewhere ? ' Neither run found anything open.' : ' Nothing to re-check - there was nothing open.');
|
|
245
369
|
lines.push('');
|
|
246
370
|
}
|
|
247
371
|
|
|
@@ -262,6 +386,7 @@ function describe(result) {
|
|
|
262
386
|
|
|
263
387
|
module.exports = {
|
|
264
388
|
keyOf: keyOf,
|
|
389
|
+
elsewhereOf: elsewhereOf,
|
|
265
390
|
compare: compare,
|
|
266
391
|
describe: describe,
|
|
267
392
|
badgeLines: badgeLines,
|