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/attack.js +172 -29
- package/bin/kryptheon-night.js +70 -16
- package/engine.sql +2883 -2628
- package/finding.js +21 -6
- package/intro.js +13 -5
- package/orphan.js +5 -37
- package/package.json +6 -2
- package/scan.js +141 -33
- package/schema.js +125 -9
- package/trouble.js +29 -3
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
|
|
367
|
-
finding.
|
|
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
|
-
|
|
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
|
|
663
|
-
'
|
|
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
|
-
|
|
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
|
|
35
|
-
'
|
|
36
|
-
'
|
|
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
|
-
'
|
|
39
|
-
'
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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.
|
|
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
|
-
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
const
|
|
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(
|
|
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()
|
|
660
|
-
*
|
|
661
|
-
* CREATE POLICY fail. Only
|
|
662
|
-
* A name followed by more identifier characters
|
|
663
|
-
* members_log is not members - so the match stops at a
|
|
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
|
|
193
|
-
'
|
|
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
|
'',
|