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/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 === 'privileged' ? 'verification required' : 'confirmed',
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 === 'privileged' ? '' : cause.long,
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
- ' Every row I read is a row I put there myself a moment earlier.',
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.25",
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
- 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:');
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 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.')
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 again, and its rules answered instead of stopping with an error.'
201
- : '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.');
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
- lines.push(' These are not fixed and not broken - they are unknown. The problem');
226
- 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
+ }
227
344
  lines.push('');
228
345
  }
229
346
 
230
347
  if (result.newlyBroken.length) {
231
- lines.push(' ' + result.newlyBroken.length + ' NEW ' +
232
- (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:');
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
- lines.push(' A fix can open something else. This is why the re-check looks at the');
239
- 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
+ }
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,