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/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 'A "' + finding.who + '" in a ' + oneOf(finding.parent) + ' can ' + listOf(does) + ' your ' +
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 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.'
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 a ' + oneOf(finding.parent) + ' whose role in "' + finding.via + '"."' +
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: a "' + finding.who + '" was added, and the writes went through.',
796
+ 'on a copy of the database: ' + aRole(finding.who) + ' was added, and the writes went through.',
755
797
  '',
756
- 'First tell me: should a "' + finding.who + '" be able to do that? If yes, change nothing.',
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 a "' +
763
- finding.who + '" should still see these rows.',
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 a "' + finding.who + '" who had just joined, I ' +
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 (what === 'change' ? 'changed ' : 'deleted ') + n + (n === 1 ? ' row' : ' rows');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon-night",
3
- "version": "0.1.26",
3
+ "version": "0.1.29",
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",
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
- lines.push(' ' + result.fixed.length + (result.fixed.length === 1 ? ' problem is' : ' problems are') + ' fixed:');
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 again, and a visitor with no account can no longer call it.'
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 again, and its rules answered instead of stopping with an error.'
205
- : 'I ran the same attack again and it was refused.';
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
- lines.push(' These are not fixed and not broken - they are unknown. The problem');
230
- lines.push(' stopped showing up, but not because the attack was refused.');
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
- lines.push(' ' + result.newlyBroken.length + ' NEW ' +
236
- (result.newlyBroken.length === 1 ? 'problem' : 'problems') + ' that were not there before:');
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
- lines.push(' A fix can open something else. This is why the re-check looks at the');
243
- lines.push(' whole app again and not only at what it was asked about.');
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
- const ranked = await engine.teammate(client, copyName, theirs, sown.seeded);
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
- process.exitCode = result.stopped ? 2 : verdict.allClear ? 0 : 1;
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