kryptheon-night 0.1.26 → 0.1.29
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 +1 -1
- package/attack.js +63 -2
- package/bin/kryptheon-night.js +3 -1
- package/collision.js +15 -0
- package/engine.sql +365 -63
- package/finding.js +84 -18
- package/package.json +1 -1
- package/recheck.js +135 -14
- package/scan.js +23 -5
- package/sqlengine.js +4 -4
- package/tamper.js +243 -52
package/finding.js
CHANGED
|
@@ -89,6 +89,22 @@ function listOf(items) {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/** One of a table's things: workspaces -> workspace, companies -> company. */
|
|
92
|
+
/** A role, quoted, with its article: a "viewer", an "analyst". */
|
|
93
|
+
function aRole(role) {
|
|
94
|
+
return (/^[aeiou]/i.test(String(role)) ? 'an "' : 'a "') + role + '"';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The first letter made a capital, for the start of a sentence. */
|
|
98
|
+
function capital(text) {
|
|
99
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The same, with its article: a workspace, an organization. */
|
|
103
|
+
function aOne(table) {
|
|
104
|
+
const one = oneOf(table);
|
|
105
|
+
return (/^[aeiou]/i.test(one) ? 'an ' : 'a ') + one;
|
|
106
|
+
}
|
|
107
|
+
|
|
92
108
|
function oneOf(table) {
|
|
93
109
|
return String(table || 'team').replace(/ies$/, 'y').replace(/s$/, '');
|
|
94
110
|
}
|
|
@@ -110,7 +126,7 @@ function severityOf(finding, contents) {
|
|
|
110
126
|
// because the app is broken for whoever it fails for, but not a break-in.
|
|
111
127
|
return 'HIGH';
|
|
112
128
|
}
|
|
113
|
-
if (finding.kind === 'role' || finding.kind === 'bucket') {
|
|
129
|
+
if (finding.kind === 'role' || finding.kind === 'teamread' || finding.kind === 'bucket') {
|
|
114
130
|
// Something to check, never a confirmed break: whether a viewer may edit,
|
|
115
131
|
// or a bucket may be public, is the owner's decision. So never CRITICAL.
|
|
116
132
|
return 'HIGH';
|
|
@@ -345,9 +361,13 @@ function headlineFor(finding) {
|
|
|
345
361
|
if (finding.kind === 'role') {
|
|
346
362
|
const order = ['delete', 'change', 'add'];
|
|
347
363
|
const does = order.filter((what) => (finding.can || []).includes(what)).map((what) => WRITE_WORDS[what]);
|
|
348
|
-
return
|
|
364
|
+
return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can ' + listOf(does) + ' your ' +
|
|
349
365
|
finding.table + ' table.';
|
|
350
366
|
}
|
|
367
|
+
if (finding.kind === 'teamread') {
|
|
368
|
+
return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can read your ' + finding.table +
|
|
369
|
+
' table, which holds ' + listOf(finding.secrets || []) + '.';
|
|
370
|
+
}
|
|
351
371
|
if (finding.kind === 'bucket') {
|
|
352
372
|
return 'Your ' + finding.table + ' storage bucket is public, but a rule says only some people may read it.';
|
|
353
373
|
}
|
|
@@ -439,16 +459,38 @@ function bodyFor(finding, contents) {
|
|
|
439
459
|
// edit is a hole in one app and the design of another.
|
|
440
460
|
const did = [];
|
|
441
461
|
const rows = (n) => n + (n === 1 ? ' row' : ' rows');
|
|
462
|
+
if ((finding.can || []).includes('add')) did.push('added ' + rows((finding.changed || {}).add || 1));
|
|
442
463
|
if ((finding.can || []).includes('change')) did.push('changed ' + rows((finding.changed || {}).change || 1));
|
|
443
464
|
if ((finding.can || []).includes('delete')) did.push('deleted ' + rows((finding.changed || {}).delete || 1));
|
|
444
465
|
const place = oneOf(finding.parent);
|
|
466
|
+
// The roles below it that were tried first and could not, when there are
|
|
467
|
+
// any: a role in the middle is named because the ones under it held.
|
|
468
|
+
const below = (finding.below || []).map((r) => '"' + r + '"');
|
|
445
469
|
return (
|
|
446
|
-
'I put one of my fake people into the other one\'s ' + place + ' as
|
|
447
|
-
'
|
|
448
|
-
listOf(did) + ' in ' + finding.table + '
|
|
449
|
-
'
|
|
450
|
-
'
|
|
451
|
-
|
|
470
|
+
'I put one of my fake people into the other one\'s ' + place + ' as ' + aRole(finding.who) +
|
|
471
|
+
' - ' + (below.length ? 'a role' : 'the lowest role') + ' in ' + finding.via + '.' + finding.roleColumn +
|
|
472
|
+
' - and, signed in as them, I ' + listOf(did) + ' in ' + finding.table + ' for that ' + place +
|
|
473
|
+
', on a copy of your app. Before joining, they could not.' +
|
|
474
|
+
(below.length ? ' As ' + listOf(below).replace(/ and /, ' or ') + ' they could not either, and no rule on this ' +
|
|
475
|
+
'table lists ' + aRole(finding.who).replace(/^an? /, '') + ' among the roles allowed to make that change.' : '') +
|
|
476
|
+
' Every one of those was undone straight away. Whether ' + aRole(finding.who) + ' may do this is your ' +
|
|
477
|
+
'decision: if it is meant to, nothing needs fixing; if not, the rules on this table let them do more.'
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (finding.kind === 'teamread') {
|
|
482
|
+
// Said as what was read and by whom, and which roles below it could not:
|
|
483
|
+
// a dispatcher seeing a key may be the design, a viewer rarely is.
|
|
484
|
+
const place = oneOf(finding.parent);
|
|
485
|
+
const n = finding.readable || 1;
|
|
486
|
+
const below = (finding.below || []).map((r) => '"' + r + '"');
|
|
487
|
+
return (
|
|
488
|
+
'I put one of my fake people into the other one\'s ' + place + ' as ' + aRole(finding.who) + ' and, signed ' +
|
|
489
|
+
'in as them, read ' + n + (n === 1 ? ' row' : ' rows') + ' of ' + finding.table + ' for that ' + place +
|
|
490
|
+
', on a copy of your app - including ' + listOf(finding.secrets || []) + '. Before joining, they could not.' +
|
|
491
|
+
(below.length ? ' As ' + listOf(below).replace(/ and /, ' or ') + ' they could not read it either.' : '') +
|
|
492
|
+
' Nothing was changed. Whether ' + aRole(finding.who) + ' should see these is your decision: if not, a read ' +
|
|
493
|
+
'rule on this table lets them.'
|
|
452
494
|
);
|
|
453
495
|
}
|
|
454
496
|
|
|
@@ -747,23 +789,42 @@ function fixPromptFor(finding) {
|
|
|
747
789
|
? [
|
|
748
790
|
'My app may have a permissions problem - please check it rather than assume it.',
|
|
749
791
|
'',
|
|
750
|
-
'In my database, a member of
|
|
792
|
+
'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
|
|
751
793
|
finding.roleColumn + '" is "' + finding.who + '" can ' +
|
|
752
|
-
listOf((finding.can || []).map((what) => ({ change: 'change', delete: 'delete' })[what])) +
|
|
794
|
+
listOf((finding.can || []).map((what) => ({ add: 'add', change: 'change', delete: 'delete' })[what])) +
|
|
753
795
|
' rows of that ' + oneOf(finding.parent) + ' in the "' + finding.table + '" table. This was proved ' +
|
|
754
|
-
'on a copy of the database:
|
|
796
|
+
'on a copy of the database: ' + aRole(finding.who) + ' was added, and the writes went through.',
|
|
755
797
|
'',
|
|
756
|
-
'First tell me: should
|
|
798
|
+
'First tell me: should ' + aRole(finding.who) + ' be able to do that? If yes, change nothing.',
|
|
757
799
|
'',
|
|
758
800
|
'If not, make the rules on "' + finding.table + '" that cover UPDATE, DELETE, INSERT or ALL check ' +
|
|
759
801
|
'the member\'s role as well as membership - for example with a helper that reads "' + finding.via +
|
|
760
802
|
'" for auth.uid() and the roles allowed to edit. Postgres lets a write through if ANY permissive ' +
|
|
761
803
|
'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
|
|
763
|
-
finding.who + '
|
|
804
|
+
finding.table + '" and narrow or drop the loose one. Keep the read rule as it is if ' +
|
|
805
|
+
aRole(finding.who) + ' should still see these rows.',
|
|
764
806
|
'',
|
|
765
807
|
'Then check every other table where members can write for the same thing.',
|
|
766
808
|
]
|
|
809
|
+
: finding.kind === 'teamread'
|
|
810
|
+
? [
|
|
811
|
+
'My app may have a permissions problem - please check it rather than assume it.',
|
|
812
|
+
'',
|
|
813
|
+
'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
|
|
814
|
+
finding.roleColumn + '" is "' + finding.who + '" can read the "' + finding.table + '" table of that ' +
|
|
815
|
+
oneOf(finding.parent) + ', including ' + listOf((finding.secrets || []).map((c) => '"' + c + '"')) +
|
|
816
|
+
'. This was proved on a copy of the database: ' + aRole(finding.who) + ' was added, and the rows came back.',
|
|
817
|
+
'',
|
|
818
|
+
'First tell me: should ' + aRole(finding.who) + ' see these? If yes, change nothing.',
|
|
819
|
+
'',
|
|
820
|
+
'If not, make the rules on "' + finding.table + '" that cover SELECT or ALL check the member\'s role as ' +
|
|
821
|
+
'well as membership. Postgres lets a read through if ANY permissive policy allows it, so a strict rule ' +
|
|
822
|
+
'beside a loose one does nothing: list every such policy on "' + finding.table + '" and narrow or drop ' +
|
|
823
|
+
'the loose one. If members need the other columns, a view or function that leaves the secret ones out ' +
|
|
824
|
+
'is often the better fix.',
|
|
825
|
+
'',
|
|
826
|
+
'Then check every other table that holds tokens, keys, secrets or payloads for the same thing.',
|
|
827
|
+
]
|
|
767
828
|
: finding.kind === 'bucket'
|
|
768
829
|
? [
|
|
769
830
|
'My app may have a storage problem - please check it rather than assume it.',
|
|
@@ -958,7 +1019,7 @@ function fixPromptFor(finding) {
|
|
|
958
1019
|
|
|
959
1020
|
// Found and said, but never claimed as a break: the owner decides. Counted
|
|
960
1021
|
// apart in the report, never towards the exit code or a clean re-check.
|
|
961
|
-
const TO_CHECK = ['privileged', 'role', 'bucket'];
|
|
1022
|
+
const TO_CHECK = ['privileged', 'role', 'teamread', 'bucket'];
|
|
962
1023
|
|
|
963
1024
|
/** Everything the report needs about one thing the attack found. */
|
|
964
1025
|
function describe(finding) {
|
|
@@ -994,6 +1055,8 @@ function describe(finding) {
|
|
|
994
1055
|
can: finding.can,
|
|
995
1056
|
via: finding.via,
|
|
996
1057
|
roleColumn: finding.roleColumn,
|
|
1058
|
+
readable: finding.readable,
|
|
1059
|
+
secrets: finding.secrets,
|
|
997
1060
|
bucket: finding.bucket,
|
|
998
1061
|
proof: finding.kind === 'recursive'
|
|
999
1062
|
? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
|
|
@@ -1001,11 +1064,14 @@ function describe(finding) {
|
|
|
1001
1064
|
: finding.kind === 'privileged'
|
|
1002
1065
|
? 'I did not call "' + finding.fn + '". This is a reach that exists in the grants, not a break I ran.'
|
|
1003
1066
|
: finding.kind === 'role'
|
|
1004
|
-
? 'As
|
|
1067
|
+
? 'As ' + aRole(finding.who) + ' who had just joined, I ' +
|
|
1005
1068
|
listOf((finding.can || []).map((what) => {
|
|
1006
1069
|
const n = (finding.changed || {})[what] || 1;
|
|
1007
|
-
return (
|
|
1070
|
+
return ({ add: 'added ', change: 'changed ', delete: 'deleted ' })[what] + n + (n === 1 ? ' row' : ' rows');
|
|
1008
1071
|
})) + ' in "' + finding.table + '" that I could not touch before joining. All of it was undone.'
|
|
1072
|
+
: finding.kind === 'teamread'
|
|
1073
|
+
? 'As ' + aRole(finding.who) + ' who had just joined, I read ' + (finding.readable || 1) +
|
|
1074
|
+
((finding.readable || 1) === 1 ? ' row' : ' rows') + ' of "' + finding.table + '" that I could not read before joining.'
|
|
1009
1075
|
: finding.kind === 'bucket'
|
|
1010
1076
|
? 'I read that "' + finding.bucket + '" is public and that ' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
|
|
1011
1077
|
' says who may read it. I did not open any file.'
|
|
@@ -1113,7 +1179,7 @@ function describeAll(findings) {
|
|
|
1113
1179
|
// Writes above reads: a table somebody emptied is worse than one they read.
|
|
1114
1180
|
// A looping rule sits with the reads: it breaks the app for everyone it
|
|
1115
1181
|
// fails for, which matters more than a duplicate or a stray row.
|
|
1116
|
-
const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, duplicated: 3, orphaned: 4, role: 4.5, bucket: 4.7, privileged: 5 };
|
|
1182
|
+
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 };
|
|
1117
1183
|
const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
|
|
1118
1184
|
return described.sort((a, b) => rank(a) - rank(b));
|
|
1119
1185
|
}
|
package/package.json
CHANGED
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 &&
|
|
@@ -173,11 +226,58 @@ function whatOf(item) {
|
|
|
173
226
|
if (item.kind === 'orphaned') return 'rows left pointing at nothing';
|
|
174
227
|
if (item.kind === 'privileged') return 'a function anyone can call';
|
|
175
228
|
if (item.kind === 'role') return 'changed by a "' + (item.who || 'viewer') + '"';
|
|
229
|
+
if (item.kind === 'teamread') return 'read by a "' + (item.who || 'viewer') + '"';
|
|
176
230
|
if (item.kind === 'bucket') return 'a public storage bucket';
|
|
177
231
|
if (item.kind === 'recursive') return 'a rule that refers to itself';
|
|
178
232
|
return item.kind;
|
|
179
233
|
}
|
|
180
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
|
+
|
|
181
281
|
/** What the re-check says, in the order it matters. */
|
|
182
282
|
function describe(result) {
|
|
183
283
|
const lines = [''];
|
|
@@ -190,26 +290,34 @@ function describe(result) {
|
|
|
190
290
|
return lines;
|
|
191
291
|
}
|
|
192
292
|
|
|
293
|
+
const elsewhere = result.elsewhere;
|
|
294
|
+
if (elsewhere) elsewhereLines(elsewhere).forEach((l) => lines.push(l));
|
|
295
|
+
|
|
193
296
|
if (result.fixed.length) {
|
|
194
|
-
|
|
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:');
|
|
195
301
|
lines.push('');
|
|
196
302
|
for (const item of result.fixed) {
|
|
197
303
|
// A function is never called, only read - so its fix is said as what
|
|
198
304
|
// was seen, not as an attack that was refused. Found on a fix test.
|
|
199
305
|
const how = item.kind === 'privileged'
|
|
200
|
-
? '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.')
|
|
201
308
|
: item.kind === 'bucket'
|
|
202
|
-
? 'I looked again, and the bucket is no longer public.'
|
|
309
|
+
? (elsewhere ? 'I looked here, and the bucket is not public.' : 'I looked again, and the bucket is no longer public.')
|
|
203
310
|
: item.kind === 'recursive'
|
|
204
|
-
? 'I read it
|
|
205
|
-
|
|
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.');
|
|
206
314
|
lines.push(' ' + item.table + ' (' + whatOf(item) + ') - ' + how);
|
|
207
315
|
}
|
|
208
316
|
lines.push('');
|
|
209
317
|
}
|
|
210
318
|
|
|
211
319
|
if (result.stillOpen.length) {
|
|
212
|
-
lines.push(' ' + result.stillOpen.length + ' still open:');
|
|
320
|
+
lines.push(' ' + result.stillOpen.length + (elsewhere ? ' found in both:' : ' still open:'));
|
|
213
321
|
lines.push('');
|
|
214
322
|
for (const item of result.stillOpen) {
|
|
215
323
|
lines.push(' ' + item.table + ' - ' + item.headline);
|
|
@@ -220,32 +328,44 @@ function describe(result) {
|
|
|
220
328
|
// Deliberately not under "fixed", and deliberately not silent. This is the
|
|
221
329
|
// one a person would otherwise read as good news.
|
|
222
330
|
if (result.unverifiable.length) {
|
|
223
|
-
lines.push(' ' + result.unverifiable.length + ' I could NOT confirm:');
|
|
331
|
+
lines.push(' ' + result.unverifiable.length + (elsewhere ? ' I could NOT compare:' : ' I could NOT confirm:'));
|
|
224
332
|
lines.push('');
|
|
225
333
|
for (const item of result.unverifiable) {
|
|
226
334
|
lines.push(' ' + item.table + ' (' + whatOf(item) + ') - ' + item.why);
|
|
227
335
|
}
|
|
228
336
|
lines.push('');
|
|
229
|
-
|
|
230
|
-
|
|
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
|
+
}
|
|
231
344
|
lines.push('');
|
|
232
345
|
}
|
|
233
346
|
|
|
234
347
|
if (result.newlyBroken.length) {
|
|
235
|
-
|
|
236
|
-
|
|
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:');
|
|
237
352
|
lines.push('');
|
|
238
353
|
for (const item of result.newlyBroken) {
|
|
239
354
|
lines.push(' ' + item.table + ' - ' + item.headline);
|
|
240
355
|
}
|
|
241
356
|
lines.push('');
|
|
242
|
-
|
|
243
|
-
|
|
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
|
+
}
|
|
244
364
|
lines.push('');
|
|
245
365
|
}
|
|
246
366
|
|
|
247
367
|
if (!result.fixed.length && !result.stillOpen.length && !result.unverifiable.length && !result.newlyBroken.length) {
|
|
248
|
-
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.');
|
|
249
369
|
lines.push('');
|
|
250
370
|
}
|
|
251
371
|
|
|
@@ -266,6 +386,7 @@ function describe(result) {
|
|
|
266
386
|
|
|
267
387
|
module.exports = {
|
|
268
388
|
keyOf: keyOf,
|
|
389
|
+
elsewhereOf: elsewhereOf,
|
|
269
390
|
compare: compare,
|
|
270
391
|
describe: describe,
|
|
271
392
|
badgeLines: badgeLines,
|
package/scan.js
CHANGED
|
@@ -153,7 +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
|
+
teammate: (client, into, tables, seeded, rules) => tamper.teammate(client, into, tables, seeded, rules),
|
|
157
157
|
};
|
|
158
158
|
}
|
|
159
159
|
|
|
@@ -305,7 +305,10 @@ async function scan(client, sourceSchema, options) {
|
|
|
305
305
|
|
|
306
306
|
// What the least trusted member of a team can change there. Rolled back
|
|
307
307
|
// like the writes above, and the joining with them.
|
|
308
|
-
|
|
308
|
+
// With the copy's rules and helpers, so a role a rule lists as allowed to
|
|
309
|
+
// write is not reported (tamper.allowedByRules).
|
|
310
|
+
const ranked = await engine.teammate(client, copyName, theirs, sown.seeded,
|
|
311
|
+
{ policies: copyPlan.policies, functions: copyPlan.functions });
|
|
309
312
|
for (const key of ranked.completed) attempted.push(key);
|
|
310
313
|
for (const stuck of ranked.blocked) {
|
|
311
314
|
notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
|
|
@@ -416,8 +419,21 @@ async function scan(client, sourceSchema, options) {
|
|
|
416
419
|
// copy, the way seeding did.
|
|
417
420
|
const tagged = taggerFor(plan, theirs);
|
|
418
421
|
|
|
422
|
+
// Where this run was, so a re-check can tell a database it saw before from
|
|
423
|
+
// another one entirely (recheck.elsewhereOf). The name and address only.
|
|
424
|
+
const at = (await client.query(
|
|
425
|
+
'SELECT current_database() AS database, host(inet_server_addr()) AS host, inet_server_port() AS port',
|
|
426
|
+
)).rows[0];
|
|
427
|
+
|
|
419
428
|
return {
|
|
420
429
|
stopped: null,
|
|
430
|
+
where: {
|
|
431
|
+
database: at.database,
|
|
432
|
+
host: at.host || null,
|
|
433
|
+
port: at.port || null,
|
|
434
|
+
schema: sourceSchema,
|
|
435
|
+
tables: plan.tables.map((table) => table.name).sort(),
|
|
436
|
+
},
|
|
421
437
|
attacksRun: attempted.length,
|
|
422
438
|
notChecked: notChecked,
|
|
423
439
|
attempted: attempted,
|
|
@@ -786,8 +802,8 @@ function report(result) {
|
|
|
786
802
|
if (toVerify.length) {
|
|
787
803
|
// A viewer who can edit was really tried; a function or a bucket was only
|
|
788
804
|
// 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');
|
|
805
|
+
const tried = toVerify.some((f) => f.kind === 'role' || f.kind === 'teamread');
|
|
806
|
+
const spotted = toVerify.some((f) => f.kind !== 'role' && f.kind !== 'teamread');
|
|
791
807
|
const it = toVerify.length === 1 ? 'it' : 'them';
|
|
792
808
|
line(' ' + toVerify.length + ' thing' + (toVerify.length === 1 ? '' : 's') + ' to check - ' +
|
|
793
809
|
(spotted && !tried ? 'I did not attack ' + it + ', only spotted the risk.'
|
|
@@ -912,7 +928,9 @@ async function main() {
|
|
|
912
928
|
if (result.findings.length) report(result);
|
|
913
929
|
else notTestedLines(result).forEach(line);
|
|
914
930
|
saveRun(file, result);
|
|
915
|
-
|
|
931
|
+
// Two different databases: the verdict is a comparison, so the exit code
|
|
932
|
+
// is this run's own answer, the same as a first run.
|
|
933
|
+
process.exitCode = result.stopped ? 2 : verdict.elsewhere ? exitCodeFor(result) : verdict.allClear ? 0 : 1;
|
|
916
934
|
} finally {
|
|
917
935
|
await client.end();
|
|
918
936
|
}
|
package/sqlengine.js
CHANGED
|
@@ -115,10 +115,10 @@ async function tamper(client, target, into, tables, seeded, policies) {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
/** What the least trusted member of a team can change. Rolled back inside the engine. */
|
|
118
|
-
async function teammate(client, target, into, tables, seeded) {
|
|
118
|
+
async function teammate(client, target, into, tables, seeded, rules) {
|
|
119
119
|
const { rows } = await client.query(
|
|
120
|
-
'SELECT ' + quote(target) + '.teammate($1, $2::jsonb, $3::jsonb) AS answer',
|
|
121
|
-
[into, JSON.stringify(tables), JSON.stringify(seeded)],
|
|
120
|
+
'SELECT ' + quote(target) + '.teammate($1, $2::jsonb, $3::jsonb, $4::jsonb) AS answer',
|
|
121
|
+
[into, JSON.stringify(tables), JSON.stringify(seeded), JSON.stringify(rules || {})],
|
|
122
122
|
);
|
|
123
123
|
return rows[0].answer;
|
|
124
124
|
}
|
|
@@ -171,7 +171,7 @@ function adapterFor(target) {
|
|
|
171
171
|
tamper: (client, into, tables, sown, policies) =>
|
|
172
172
|
tamper(client, target, into, tables, sown, policies),
|
|
173
173
|
orphan: (client, into, tables, sown) => orphan(client, target, into, tables, sown),
|
|
174
|
-
teammate: (client, into, tables, sown) => teammate(client, target, into, tables, sown),
|
|
174
|
+
teammate: (client, into, tables, sown, rules) => teammate(client, target, into, tables, sown, rules),
|
|
175
175
|
};
|
|
176
176
|
}
|
|
177
177
|
|