kryptheon-night 0.1.19 → 0.1.25

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
@@ -291,6 +291,17 @@ function causeOf(finding) {
291
291
  /** What a caller could do, written the way a person would say it. */
292
292
  const WRITE_WORDS = { add: 'add rows to', change: 'change rows in', delete: 'delete rows from' };
293
293
 
294
+ /**
295
+ * The error a looping rule produces, as Postgres words it. A rule that reads
296
+ * its own table directly is caught as recursion and named; one that reaches
297
+ * it through a helper function runs out of stack, and Postgres names nothing.
298
+ */
299
+ function loopError(finding) {
300
+ return finding.deep
301
+ ? '"stack depth limit exceeded"'
302
+ : '"infinite recursion detected in policy for relation ' + finding.table + '"';
303
+ }
304
+
294
305
  /** Who a looping rule fails for, said the way the headline and body need it. */
295
306
  function loopVictims(finding) {
296
307
  const callers = finding.callers || [];
@@ -363,8 +374,9 @@ function bodyFor(finding, contents) {
363
374
  const one = reads.length === 1;
364
375
  return (
365
376
  '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') +
377
+ ', every request was refused with ' + loopError(finding) +
378
+ (finding.deep ? ' - a rule on ' + finding.table + ' calls a helper that reads ' + finding.table + ' again, which runs the rule again' : '') +
379
+ '. Every page in your app that uses ' + (one ? 'that table' : 'those tables') +
368
380
  ' fails the same way for ' + loopVictims(finding).replace(/,$/, '') + '. It also means I could ' +
369
381
  'not test ' + (one ? 'it' : 'them') + ' for leaks - those attacks are listed at the end.'
370
382
  );
@@ -448,7 +460,10 @@ function bodyFor(finding, contents) {
448
460
  }
449
461
  return (
450
462
  'Anyone on the internet, without logging in and without an account, can read ' +
451
- 'this table. I did it myself just now and got back ' + finding.readable + ' ' + rowWord +
463
+ // Read back the next morning, "just now" would be untrue: it was the
464
+ // nightly run, hours earlier.
465
+ 'this table. ' + (finding.fromNight ? 'The nightly run did it on a copy and got back ' : 'I did it myself just now and got back ') +
466
+ finding.readable + ' ' + rowWord +
452
467
  (holds ? ', including ' + holds : '') + '.'
453
468
  );
454
469
  }
@@ -659,8 +674,8 @@ function fixPromptFor(finding) {
659
674
  'My app has a broken database rule.',
660
675
  '',
661
676
  'A row level security policy on the "' + finding.table + '" table reads "' + finding.table +
662
- '" itself, so Postgres stops every request that touches it with "infinite recursion ' +
663
- 'detected in policy for relation ' + finding.table + '". I saw it when using ' +
677
+ '" itself' + (finding.deep ? ', through a helper function it calls,' : '') +
678
+ ' so Postgres stops every request that touches it with ' + loopError(finding) + '. I saw it when using ' +
664
679
  listOf((finding.reads || []).map((t) => '"' + t + '"')) + '.',
665
680
  '',
666
681
  'Show me the policies on "' + finding.table + '" and on every table whose policy reads "' +
@@ -882,7 +897,7 @@ function describe(finding) {
882
897
  can: finding.can,
883
898
  proof: finding.kind === 'recursive'
884
899
  ? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
885
- '"infinite recursion detected in policy for relation ' + finding.table + '".'
900
+ loopError(finding) + '.'
886
901
  : finding.kind === 'privileged'
887
902
  ? 'I did not call "' + finding.fn + '". This is a reach that exists in the grants, not a break I ran.'
888
903
  : finding.kind === 'duplicated'
package/intro.js CHANGED
@@ -28,15 +28,23 @@ const trouble = require('./trouble.js');
28
28
  * their users end up on the same page by a different door.
29
29
  */
30
30
  function whereToFindIt() {
31
+ // The session pooler, and said as such: the direct connection answers only
32
+ // over IPv6, which many home networks do not have, and the transaction
33
+ // pooler on 6543 cannot hold these attacks together. The kryptheon
34
+ // dashboard shows these same steps; kryptheon-night words hands them over.
31
35
  return [
32
36
  'Where to find it:',
33
37
  '',
34
- ' Supabase supabase.com/dashboard -> your project -> the gear icon',
35
- ' (Project Settings) at the bottom left -> Database ->',
36
- ' scroll to "Connection string" -> the URI tab -> Copy.',
38
+ ' Supabase 1. supabase.com/dashboard -> open your project.',
39
+ ' 2. Press "Connect" at the top of the page.',
40
+ ' 3. Choose "Session pooler" - the one on port 5432.',
41
+ ' 4. Copy the URI. It starts postgresql://',
42
+ ' 5. Replace [YOUR-PASSWORD] in it with your database password.',
43
+ ' Forgotten it? Project Settings -> Database -> Reset',
44
+ ' database password.',
37
45
  '',
38
- ' Replace [YOUR-PASSWORD] in it with your database password.',
39
- ' It is on that same page under "Database password".',
46
+ ' Not port 6543 (the transaction pooler), and not the direct',
47
+ ' db.<project>.supabase.co address - both are warned about.',
40
48
  '',
41
49
  ' Lovable your app uses Supabase underneath. Open the Supabase',
42
50
  ' and Bolt project it made for you and follow the lines above.',
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.19",
3
+ "version": "0.1.25",
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 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 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",
@@ -56,6 +56,10 @@
56
56
  "check:recursion": "node recursion.check.js",
57
57
  "check:guarded": "node guarded.check.js",
58
58
  "check:nightly": "node nightly.check.js",
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",
59
63
  "check:cli": "node cli.check.js",
60
64
  "check:writes": "node tamper.check.js",
61
65
  "check:orphans": "node orphans.check.js",
package/scan.js CHANGED
@@ -320,16 +320,24 @@ async function scan(client, sourceSchema, options) {
320
320
  // app is broken for the people using it. One finding per table at fault,
321
321
  // named from Postgres's own message, however many reads ran into it.
322
322
  const loops = new Map();
323
+ // A loop that named no table ("stack depth limit exceeded") is put on the
324
+ // tables whose own rule calls a helper that reads that same table; if
325
+ // none can be found, on the table that was being read.
326
+ const culprits = loopingTables(copyPlan);
323
327
  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);
328
+ const deep = !relation;
329
+ for (const at of relation ? [relation] : culprits.length ? culprits : [table]) {
330
+ if (!loops.has(at)) loops.set(at, { kind: 'recursive', table: at, reads: [], callers: [], columns: [], deep: false });
331
+ const entry = loops.get(at);
332
+ if (deep) entry.deep = true;
333
+ if (!entry.reads.includes(table)) entry.reads.push(table);
334
+ if (!entry.callers.includes(who)) entry.callers.push(who);
335
+ }
328
336
  };
329
337
  for (const hit of impersonation.looped || []) loopedInto(hit.relation, hit.table, hit.who);
330
338
  for (const stuck of writes.blocked) {
331
339
  const relation = attack.recursionIn(stuck.why);
332
- if (relation) loopedInto(relation, stuck.table, /^as anyone:/.test(stuck.why) ? 'anyone' : 'signed-in');
340
+ if (relation || attack.tooDeep(stuck.why)) loopedInto(relation || null, stuck.table, /^as anyone:/.test(stuck.why) ? 'anyone' : 'signed-in');
333
341
  }
334
342
  const recursive = Array.from(loops.values());
335
343
 
@@ -385,32 +393,12 @@ async function scan(client, sourceSchema, options) {
385
393
  // version recorded only the flagged ones, so a revoke made the function
386
394
  // vanish from both lists and the fix came back as "could not confirm".
387
395
  for (const fn of plan.anonFunctions || []) attempted.push('privileged:' + fn.name);
388
- // Quiet: a yes/no about the caller, and a function that turns the caller
389
- // away before it does anything (schema.guardedAtTheDoor).
390
- const privileged = (plan.anonFunctions || []).filter((fn) => fn.callable && !fn.aboutCaller && !fn.guarded).map((fn) => ({
391
- kind: 'privileged',
392
- fn: fn.name,
393
- table: fn.name,
394
- args: fn.args,
395
- writes: fn.writes,
396
- hasFixedSearchPath: fn.hasFixedSearchPath,
397
- columns: [],
398
- }));
396
+ const privileged = privilegedOf(plan);
399
397
 
400
- // Whether each table is tied to Supabase's sign-in decides what its fix
401
- // can be. Read from the original schema: the copy points auth.users keys
402
- // at a stand-in, and would call every table untied.
403
- const ties = finding.authTiesOf(plan);
404
- // And whether a row says whose it is: with no such column, no rule can
405
- // tie it to the person asking. Read from the copy, the way seeding did.
406
- // The column's name travels too: a team's table that also says who made
407
- // each row needs it for its prompt, even on a finding that is not about
408
- // owners at all.
409
- const owned = new Map(theirs.map((table) => [table.name, attack.ownerColumn(table)]));
410
- const tagged = (f) => Object.assign({}, f,
411
- ties.has(f.table) ? { authTied: ties.get(f.table) } : {},
412
- owned.has(f.table) ? { owned: Boolean(owned.get(f.table)) } : {},
413
- owned.get(f.table) ? { ownedBy: owned.get(f.table) } : {});
398
+ // Ties read from the original schema: the copy points auth.users keys at
399
+ // a stand-in, and would call every table untied. Owners read from the
400
+ // copy, the way seeding did.
401
+ const tagged = taggerFor(plan, theirs);
414
402
 
415
403
  return {
416
404
  stopped: null,
@@ -442,6 +430,105 @@ function exitCodeFor(result) {
442
430
  return (result.findings || []).some((f) => f.status !== 'verification required') ? 1 : 0;
443
431
  }
444
432
 
433
+ /**
434
+ * The tables whose own rule reaches back into the same table through the
435
+ * helpers it calls - directly or through helpers those call. Such a rule has
436
+ * to be evaluated to be evaluated, which is how "stack depth limit exceeded"
437
+ * happens; the message itself does not say which table.
438
+ */
439
+ function loopingTables(plan) {
440
+ const escape = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
441
+ const calls = (text, name) => new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?\\s*\\(').test(String(text || ''));
442
+ const mentions = (text, name) => new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?([^A-Za-z0-9_$]|$)').test(String(text || ''));
443
+ const fns = (plan && plan.functions) || [];
444
+ const reached = (text) => {
445
+ const found = new Set(fns.filter((f) => calls(text, f.name)).map((f) => f.name));
446
+ let grew = true;
447
+ while (grew) {
448
+ grew = false;
449
+ for (const f of fns) {
450
+ if (found.has(f.name)) continue;
451
+ if (fns.some((g) => found.has(g.name) && calls(g.src, f.name))) { found.add(f.name); grew = true; }
452
+ }
453
+ }
454
+ return fns.filter((f) => found.has(f.name));
455
+ };
456
+ return ((plan && plan.tables) || []).map((t) => t.name).filter((name) => {
457
+ const rules = ((plan && plan.policies) || []).filter((p) => p.table_name === name)
458
+ .map((p) => String(p.qual || '') + ' ' + String(p.with_check || '')).join(' ');
459
+ return rules.trim() && reached(rules).some((f) => mentions(f.src, name));
460
+ });
461
+ }
462
+
463
+ /**
464
+ * Functions an anonymous visitor can call that run past the rules. Read from
465
+ * the shape, not attacked. Quiet: a yes/no about the caller, and a function
466
+ * that turns the caller away before it does anything (schema.guardedAtTheDoor).
467
+ */
468
+ function privilegedOf(plan) {
469
+ return ((plan && plan.anonFunctions) || []).filter((fn) => fn.callable && !fn.aboutCaller && !fn.guarded).map((fn) => ({
470
+ kind: 'privileged',
471
+ fn: fn.name,
472
+ table: fn.name,
473
+ args: fn.args,
474
+ writes: fn.writes,
475
+ hasFixedSearchPath: fn.hasFixedSearchPath,
476
+ columns: [],
477
+ }));
478
+ }
479
+
480
+ /**
481
+ * What each finding needs to know about its table for its fix to be right:
482
+ * whether the app signs people in through Supabase, whether a row says whose
483
+ * it is, and which column that is. Without these a prompt falls back to "the
484
+ * column that says who each row belongs to" - found on a nightly read-back,
485
+ * whose findings came from the database without them.
486
+ */
487
+ function taggerFor(plan, tables) {
488
+ const ties = finding.authTiesOf(plan);
489
+ const owned = new Map((tables || []).map((table) => [table.name, attack.ownerColumn(table)]));
490
+ return (f) => Object.assign({}, f,
491
+ ties.has(f.table) ? { authTied: ties.get(f.table) } : {},
492
+ owned.has(f.table) ? { owned: Boolean(owned.get(f.table)) } : {},
493
+ owned.get(f.table) ? { ownedBy: owned.get(f.table) } : {});
494
+ }
495
+
496
+ /**
497
+ * What the nightly engine wrote down as not tried, in the shape a scan uses.
498
+ * It names a column's check as table and column apart, with no attack named,
499
+ * so "export_jobs.access_token could not be raced" was counted as a whole
500
+ * table not checked - and listed without its column.
501
+ */
502
+ function notCheckedFromNight(entries) {
503
+ return (entries || []).map((e) => {
504
+ if (!e || e.key || !e.column) return e;
505
+ const kind = /two requests at the same instant/.test(String(e.why)) ? 'duplicated' : 'orphaned';
506
+ return { table: e.table + '.' + e.column, key: kind + ':' + e.table + ':' + e.column, why: e.why };
507
+ });
508
+ }
509
+
510
+ /**
511
+ * A moment of the night, said in UTC - which is what the database runs on -
512
+ * and in this computer's own time, so 03:00 is not read as three at night
513
+ * here. Found on a real read-back: "the night of 2026-10-01 03:00" was 08:30
514
+ * in India, and nothing said so.
515
+ */
516
+ function bothTimes(iso) {
517
+ const at = new Date(iso);
518
+ return at.toISOString().slice(0, 16).replace('T', ' ') + ' UTC (' +
519
+ at.toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) + ' your time)';
520
+ }
521
+
522
+ /** "0 3 * * *" as a time of day in both, or the schedule as written. */
523
+ function scheduleTimes(cron) {
524
+ const m = /^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*$/.exec(String(cron || '').trim());
525
+ if (!m) return String(cron || 'whenever it is scheduled');
526
+ const now = new Date();
527
+ const at = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), Number(m[2]), Number(m[1])));
528
+ return 'every night at ' + at.toISOString().slice(11, 16) + ' UTC (' +
529
+ at.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) + ' your time)';
530
+ }
531
+
445
532
  /** Where the nightly run's last answer is kept for the dashboard. */
446
533
  function nightlyFile() {
447
534
  return store.open(process.cwd()).nightNightly;
@@ -453,7 +540,7 @@ function nightlyFile() {
453
540
  * as "nothing found" - not installed, installed and never run, and stopped -
454
541
  * said as themselves. `latest` is installer.latestRun's answer.
455
542
  */
456
- function nightlyRecord(latest, readAt) {
543
+ function nightlyRecord(latest, readAt, plan) {
457
544
  const at = (value) => (value ? new Date(value).toISOString() : null);
458
545
  if (!latest || !latest.installed) return { readAt: readAt, installed: false };
459
546
  const where = latest.where || {};
@@ -467,13 +554,28 @@ function nightlyRecord(latest, readAt) {
467
554
  };
468
555
  const run = latest.run;
469
556
  if (!run) return record;
557
+ // The night's findings, told as the night's: tagged with what their table
558
+ // needs for a right fix when the schema could be read, and joined by the
559
+ // functions anyone can call - read now, when this is asked, because the
560
+ // engine inside the database does not look at them.
561
+ const tag = plan ? taggerFor(plan, plan.tables) : (f) => f;
562
+ // A loop the engine could not name ("stack depth limit exceeded") is put
563
+ // on the tables whose own rule reads back into them, read from the shape.
564
+ const loops = plan ? loopingTables(plan) : [];
565
+ const night = [];
566
+ for (const f of run.findings || []) {
567
+ const at = f.kind === 'recursive' && !f.table ? (loops.length ? loops : ['one of your tables']) : [f.table];
568
+ for (const table of at) night.push(tag(Object.assign({}, f, { table: table, fromNight: true })));
569
+ }
570
+ const now = plan ? privilegedOf(plan).map(tag) : [];
470
571
  return Object.assign(record, {
471
572
  ranAt: at(run.ran_at),
472
573
  source: run.source,
473
574
  stopped: run.stopped || null,
474
575
  attacksRun: run.attacks_run || 0,
475
- notChecked: run.not_checked || [],
476
- findings: run.stopped ? [] : finding.describeAll(run.findings || []),
576
+ notChecked: notCheckedFromNight(run.not_checked),
577
+ findings: run.stopped ? [] : finding.describeAll(night.concat(now)),
578
+ functionsReadAt: plan ? readAt : null,
477
579
  });
478
580
  }
479
581
 
@@ -781,6 +883,12 @@ module.exports = {
781
883
  saveRun: saveRun,
782
884
  nightlyFile: nightlyFile,
783
885
  nightlyRecord: nightlyRecord,
886
+ bothTimes: bothTimes,
887
+ scheduleTimes: scheduleTimes,
888
+ notCheckedFromNight: notCheckedFromNight,
889
+ privilegedOf: privilegedOf,
890
+ loopingTables: loopingTables,
891
+ taggerFor: taggerFor,
784
892
  saveNightly: saveNightly,
785
893
  sweepOldCopies: sweepOldCopies,
786
894
  notTestedLines: notTestedLines,
package/schema.js CHANGED
@@ -322,6 +322,40 @@ async function readAnonDefinerFunctions(client, schema, role) {
322
322
  });
323
323
  }
324
324
 
325
+ /**
326
+ * The app's own SQL and PL/pgSQL functions, with their full definitions.
327
+ *
328
+ * Read so the helpers a rule calls can be built inside the copy. Found on a
329
+ * blind test: a rule called has_org_role(org_id, ARRAY[...]::org_role[]). The
330
+ * copy has its own org_role, the helper stayed in the original schema taking
331
+ * the original's, and no rule could be created - the scan stopped before it
332
+ * began. Left in the original, a helper also answers from the original's
333
+ * tables rather than from the copy's.
334
+ */
335
+ async function readFunctions(client, schema) {
336
+ const { rows } = await client.query(
337
+ `SELECT p.proname AS name,
338
+ l.lanname AS language,
339
+ p.prosrc AS src,
340
+ pg_get_functiondef(p.oid) AS def,
341
+ pg_get_function_identity_arguments(p.oid) AS args,
342
+ p.proacl IS NULL AS default_acl,
343
+ (SELECT coalesce(array_agg(g ORDER BY g), '{}'::text[])
344
+ FROM (SELECT DISTINCT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee)::text END AS g
345
+ FROM aclexplode(p.proacl) a
346
+ WHERE a.privilege_type = 'EXECUTE' AND a.grantee <> p.proowner) x) AS executors
347
+ FROM pg_proc p
348
+ JOIN pg_namespace n ON n.oid = p.pronamespace
349
+ JOIN pg_language l ON l.oid = p.prolang
350
+ WHERE n.nspname = $1
351
+ AND p.prokind = 'f'
352
+ AND p.prorettype <> 'pg_catalog.trigger'::regtype
353
+ ORDER BY p.proname, p.oid`,
354
+ [schema],
355
+ );
356
+ return rows;
357
+ }
358
+
325
359
  async function readPolicies(client, schema) {
326
360
  const { rows } = await client.query(
327
361
  `SELECT tablename AS table_name,
@@ -597,6 +631,7 @@ async function readSchema(client, schema) {
597
631
  viewGrants: await readViewGrants(client, schema),
598
632
  external: external,
599
633
  anonFunctions: await readAnonDefinerFunctions(client, schema),
634
+ functions: await readFunctions(client, schema),
600
635
  unsupported: unsupported,
601
636
  };
602
637
  }
@@ -656,18 +691,19 @@ function rewriteSchemaRefs(expr, fromSchema, toSchema) {
656
691
  * since source and copy printed the same words.
657
692
  *
658
693
  * Not rewriteSchemaRefs, which moves every "kn_app." it finds: a rule calling
659
- * one of the app's functions (kn_app.whoami()) has to keep calling it,
660
- * because functions are not copied, and pointing it at the copy makes the
661
- * CREATE POLICY fail. Only names that are tables or views of this app move.
662
- * A name followed by more identifier characters is a different name -
663
- * members_log is not members - so the match stops at a word boundary.
694
+ * one of the app's functions that is not copied (kn_app.whoami() in another
695
+ * language) has to keep calling it, and pointing it at the copy makes the
696
+ * CREATE POLICY fail. Only tables, views, and the functions the copy was
697
+ * given (copiedFunctions) move. A name followed by more identifier characters
698
+ * is a different name - members_log is not members - so the match stops at a
699
+ * word boundary.
664
700
  */
665
- function rewriteOwnTableRefs(expr, plan, target) {
701
+ function rewriteOwnTableRefs(expr, plan, target, copiedFunctions) {
666
702
  if (expr === null || expr === undefined) return expr;
667
703
  const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
668
704
  const simple = (s) => /^[a-z_][a-z0-9_$]*$/.test(s);
669
705
  const schemaForms = [quote(plan.schema)].concat(simple(plan.schema) ? [plan.schema] : []);
670
- const own = (plan.tables || []).map((t) => t.name).concat((plan.views || []).map((v) => v.name));
706
+ const own = (plan.tables || []).map((t) => t.name).concat((plan.views || []).map((v) => v.name), copiedFunctions || []);
671
707
  let out = String(expr);
672
708
  for (const name of own) {
673
709
  const nameForms = [quote(name)].concat(simple(name) ? [name] : []);
@@ -681,6 +717,61 @@ function rewriteOwnTableRefs(expr, plan, target) {
681
717
  return out;
682
718
  }
683
719
 
720
+ /**
721
+ * Which of the app's functions the copy needs: every SQL or PL/pgSQL function
722
+ * a rule or a view calls, and every one those call in turn. Matched by name
723
+ * followed by "(", bare or schema-qualified; every overload of a name comes
724
+ * along. Functions in other languages stay where they are.
725
+ */
726
+ function functionsToCopy(plan) {
727
+ const copyable = (plan.functions || []).filter((f) => f.language === 'sql' || f.language === 'plpgsql');
728
+ const names = Array.from(new Set(copyable.map((f) => f.name)));
729
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
730
+ const calls = (text, name) => new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?\\s*\\(').test(String(text || ''));
731
+ const seeds = []
732
+ .concat((plan.policies || []).map((p) => String(p.qual || '') + ' ' + String(p.with_check || '')))
733
+ .concat((plan.views || []).map((v) => v.definition));
734
+ const wanted = new Set(names.filter((name) => seeds.some((text) => calls(text, name))));
735
+ // And whatever those call, until nothing new turns up.
736
+ let grew = true;
737
+ while (grew) {
738
+ grew = false;
739
+ for (const f of copyable) {
740
+ if (!wanted.has(f.name)) continue;
741
+ for (const name of names) {
742
+ if (!wanted.has(name) && calls(f.src, name)) {
743
+ wanted.add(name);
744
+ grew = true;
745
+ }
746
+ }
747
+ }
748
+ }
749
+ return copyable.filter((f) => wanted.has(f.name));
750
+ }
751
+
752
+ /**
753
+ * One of the app's functions, rebuilt in the copy: the same body and settings,
754
+ * its name in the copy, its references to the app's own tables, views and
755
+ * copied functions pointed at the copy, and a pinned search_path that looks in
756
+ * the copy first and the original second (for what the copy does not have,
757
+ * such as an extension's functions).
758
+ */
759
+ function copyFunctionStatement(fn, plan, target, copiedNames) {
760
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
761
+ const schemaForms = [quote(plan.schema), plan.schema].map(escape).join('|');
762
+ let def = String(fn.def).replace(
763
+ new RegExp('^CREATE OR REPLACE FUNCTION\\s+(?:' + schemaForms + ')\\.'),
764
+ () => 'CREATE FUNCTION ' + quote(target) + '.',
765
+ );
766
+ // The settings line, e.g. SET search_path TO 'public', 'storage'
767
+ def = def.replace(/^(\s*SET search_path (?:TO|=) )(.*)$/m, (all, head, list) =>
768
+ head + list.split(',').map((part) => {
769
+ const bare = part.trim().replace(/^'(.*)'$/, '$1').replace(/^"(.*)"$/, '$1');
770
+ return bare === plan.schema ? "'" + target + "', " + part.trim() : part.trim();
771
+ }).join(', '));
772
+ return rewriteOwnTableRefs(def, plan, target, copiedNames);
773
+ }
774
+
684
775
  /**
685
776
  * Points a reference with no schema on it at the copy.
686
777
  *
@@ -940,6 +1031,29 @@ async function writeSchema(client, plan, target) {
940
1031
  statements.push(rewriteSchemaRefs(index.definition, plan.schema, target));
941
1032
  }
942
1033
 
1034
+ // The helpers the rules and views call, after the tables their bodies read
1035
+ // and before anything that calls them.
1036
+ const copied = functionsToCopy(plan);
1037
+ const copiedNames = Array.from(new Set(copied.map((f) => f.name)));
1038
+ // The app's own types move with them: a copied helper taking app.role_t[]
1039
+ // has to take the copy's role_t, or the copy's columns cannot be compared
1040
+ // with its arguments.
1041
+ const movedNames = copiedNames.concat((plan.types || []).map((t) => t.name));
1042
+ for (const fn of copied) {
1043
+ statements.push(copyFunctionStatement(fn, plan, target, movedNames));
1044
+ // Who may call it, as in the original. Found by the blocked-read check: a
1045
+ // helper the original had closed to anon and authenticated came out of
1046
+ // the copy open to everyone, and a rule that cannot be evaluated was
1047
+ // reported as evaluated.
1048
+ if (!fn.default_acl) {
1049
+ const signature = quote(target) + '.' + quote(fn.name) + '(' + rewriteOwnTableRefs(fn.args, plan, target, movedNames) + ')';
1050
+ statements.push('REVOKE ALL ON FUNCTION ' + signature + ' FROM PUBLIC');
1051
+ for (const who of fn.executors || []) {
1052
+ statements.push('GRANT EXECUTE ON FUNCTION ' + signature + ' TO ' + (who === 'PUBLIC' ? 'PUBLIC' : quote(who)));
1053
+ }
1054
+ }
1055
+ }
1056
+
943
1057
  // Views last, because they read from the tables above. Copied rather than
944
1058
  // skipped because a view is a way into a table: it runs with its creator
945
1059
  // rights unless it says security_invoker, so a view over a protected table
@@ -1011,8 +1125,8 @@ async function writeSchema(client, plan, target) {
1011
1125
  'FOR ' + policy.cmd,
1012
1126
  'TO ' + roles,
1013
1127
  ];
1014
- if (policy.qual) parts.push('USING (' + rewriteOwnTableRefs(policy.qual, plan, target) + ')');
1015
- if (policy.with_check) parts.push('WITH CHECK (' + rewriteOwnTableRefs(policy.with_check, plan, target) + ')');
1128
+ if (policy.qual) parts.push('USING (' + rewriteOwnTableRefs(policy.qual, plan, target, movedNames) + ')');
1129
+ if (policy.with_check) parts.push('WITH CHECK (' + rewriteOwnTableRefs(policy.with_check, plan, target, movedNames) + ')');
1016
1130
  statements.push(parts.join(' '));
1017
1131
  }
1018
1132
 
@@ -1277,6 +1391,8 @@ module.exports = {
1277
1391
  referenceIn: referenceIn,
1278
1392
  rewriteSchemaRefs: rewriteSchemaRefs,
1279
1393
  qualifyOwnRefs: qualifyOwnRefs,
1394
+ functionsToCopy: functionsToCopy,
1395
+ copyFunctionStatement: copyFunctionStatement,
1280
1396
  stubNameFor: stubNameFor,
1281
1397
  IDENTITIES: IDENTITIES,
1282
1398
  quote: quote,
package/trouble.js CHANGED
@@ -189,8 +189,22 @@ function poolerWarning(raw) {
189
189
  'That is the transaction pooler (port 6543). It gives every statement a',
190
190
  'different connection, and these attacks have to stay on one.',
191
191
  '',
192
- 'Use the session pooler or the direct connection instead - same page in',
193
- 'Supabase, the one whose port is 5432.',
192
+ 'Use the session pooler instead: Supabase -> Connect -> Session pooler,',
193
+ 'the one whose port is 5432.',
194
+ ];
195
+ }
196
+
197
+ // The direct connection answers only over IPv6 now. On a network without
198
+ // it the name does not resolve, and the failure reads like a typo. It works
199
+ // where IPv6 does, so this warns rather than refuses.
200
+ if (/^db\.[a-z0-9]+\.supabase\.(co|com)$/i.test(url.hostname)) {
201
+ return [
202
+ 'That is Supabase\'s direct connection (db.' + url.hostname.split('.')[1] + '.supabase.co). It',
203
+ 'answers only over IPv6, and many home and office networks have none - it',
204
+ 'then fails as if the address were wrong.',
205
+ '',
206
+ 'If it does, use the session pooler: Supabase -> Connect -> Session pooler,',
207
+ 'port 5432.',
194
208
  ];
195
209
  }
196
210
 
@@ -351,7 +365,7 @@ function supabaseDirectRef(raw) {
351
365
  * survives is the stack: the person cannot use it and it makes the tool look
352
366
  * like it broke rather than like it has something to tell them.
353
367
  */
354
- function explain(err, raw) {
368
+ function explain(err, raw, options) {
355
369
  const code = err && err.code ? String(err.code) : '';
356
370
 
357
371
  // Checked before the general ENOTFOUND, because for this one address the
@@ -434,6 +448,18 @@ function explain(err, raw) {
434
448
 
435
449
  // Nothing matched. Say what happened and where, without pretending to know
436
450
  // why, and without the stack.
451
+ //
452
+ // After the connection was made, "I could not connect" is untrue - found on
453
+ // a blind test, where it was printed over a copy that could not be built.
454
+ if (options && options.connected) {
455
+ return [
456
+ 'I connected, but the check stopped part-way.',
457
+ '',
458
+ 'The server said: ' + message,
459
+ '',
460
+ 'Nothing was left behind: the temporary copy is deleted whenever a check stops.',
461
+ ];
462
+ }
437
463
  const lines = [
438
464
  'I could not connect to the database.',
439
465
  '',