kryptheon-night 0.1.22 → 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 +2 -2
- package/engine.sql +2883 -2628
- package/finding.js +17 -5
- package/orphan.js +5 -37
- package/package.json +5 -2
- package/scan.js +52 -6
- package/schema.js +125 -9
- package/trouble.js +13 -1
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
|
);
|
|
@@ -662,8 +674,8 @@ function fixPromptFor(finding) {
|
|
|
662
674
|
'My app has a broken database rule.',
|
|
663
675
|
'',
|
|
664
676
|
'A row level security policy on the "' + finding.table + '" table reads "' + finding.table +
|
|
665
|
-
'" itself
|
|
666
|
-
'
|
|
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 ' +
|
|
667
679
|
listOf((finding.reads || []).map((t) => '"' + t + '"')) + '.',
|
|
668
680
|
'',
|
|
669
681
|
'Show me the policies on "' + finding.table + '" and on every table whose policy reads "' +
|
|
@@ -885,7 +897,7 @@ function describe(finding) {
|
|
|
885
897
|
can: finding.can,
|
|
886
898
|
proof: finding.kind === 'recursive'
|
|
887
899
|
? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
|
|
888
|
-
|
|
900
|
+
loopError(finding) + '.'
|
|
889
901
|
: finding.kind === 'privileged'
|
|
890
902
|
? 'I did not call "' + finding.fn + '". This is a reach that exists in the grants, not a break I ran.'
|
|
891
903
|
: finding.kind === 'duplicated'
|
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 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 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",
|
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
|
|
|
@@ -422,6 +430,36 @@ function exitCodeFor(result) {
|
|
|
422
430
|
return (result.findings || []).some((f) => f.status !== 'verification required') ? 1 : 0;
|
|
423
431
|
}
|
|
424
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
|
+
|
|
425
463
|
/**
|
|
426
464
|
* Functions an anonymous visitor can call that run past the rules. Read from
|
|
427
465
|
* the shape, not attacked. Quiet: a yes/no about the caller, and a function
|
|
@@ -521,7 +559,14 @@ function nightlyRecord(latest, readAt, plan) {
|
|
|
521
559
|
// functions anyone can call - read now, when this is asked, because the
|
|
522
560
|
// engine inside the database does not look at them.
|
|
523
561
|
const tag = plan ? taggerFor(plan, plan.tables) : (f) => f;
|
|
524
|
-
|
|
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
|
+
}
|
|
525
570
|
const now = plan ? privilegedOf(plan).map(tag) : [];
|
|
526
571
|
return Object.assign(record, {
|
|
527
572
|
ranAt: at(run.ran_at),
|
|
@@ -842,6 +887,7 @@ module.exports = {
|
|
|
842
887
|
scheduleTimes: scheduleTimes,
|
|
843
888
|
notCheckedFromNight: notCheckedFromNight,
|
|
844
889
|
privilegedOf: privilegedOf,
|
|
890
|
+
loopingTables: loopingTables,
|
|
845
891
|
taggerFor: taggerFor,
|
|
846
892
|
saveNightly: saveNightly,
|
|
847
893
|
sweepOldCopies: sweepOldCopies,
|
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
|
@@ -365,7 +365,7 @@ function supabaseDirectRef(raw) {
|
|
|
365
365
|
* survives is the stack: the person cannot use it and it makes the tool look
|
|
366
366
|
* like it broke rather than like it has something to tell them.
|
|
367
367
|
*/
|
|
368
|
-
function explain(err, raw) {
|
|
368
|
+
function explain(err, raw, options) {
|
|
369
369
|
const code = err && err.code ? String(err.code) : '';
|
|
370
370
|
|
|
371
371
|
// Checked before the general ENOTFOUND, because for this one address the
|
|
@@ -448,6 +448,18 @@ function explain(err, raw) {
|
|
|
448
448
|
|
|
449
449
|
// Nothing matched. Say what happened and where, without pretending to know
|
|
450
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
|
+
}
|
|
451
463
|
const lines = [
|
|
452
464
|
'I could not connect to the database.',
|
|
453
465
|
'',
|