kryptheon-night 0.1.22 → 0.1.26

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