kryptheon-night 0.1.2 → 0.1.4
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/README.md +33 -0
- package/bin/kryptheon-night.js +169 -5
- package/engine.sql +2484 -0
- package/installer.js +224 -0
- package/intro.js +52 -0
- package/package.json +9 -4
- package/scan.js +38 -7
- package/schema.js +72 -8
- package/sqlengine.js +200 -0
package/installer.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// The second door: the engine stays put, and pg_cron calls it at night.
|
|
2
|
+
//
|
|
3
|
+
// Down `npx` the engine is installed into a throwaway schema, used, and
|
|
4
|
+
// dropped inside one command - the connection string never leaves the
|
|
5
|
+
// person's machine, but they have to run it. This door is for the thing they
|
|
6
|
+
// will not do every week. The same functions are installed once, pg_cron runs
|
|
7
|
+
// `nightly()` at three in the morning, and nothing about it needs a
|
|
8
|
+
// credential to move anywhere, because nothing moves: the work happens where
|
|
9
|
+
// the data already is.
|
|
10
|
+
//
|
|
11
|
+
// Everything here is written around one rule, and it is the rule fixture.js
|
|
12
|
+
// learned the expensive way: **only remove what you created**. A database
|
|
13
|
+
// that already had pg_cron in it before Kryptheon arrived must still have it
|
|
14
|
+
// afterwards, and a customer who removes Kryptheon must not be left with a
|
|
15
|
+
// nightly job nobody owns. So install writes down what it made, in a table
|
|
16
|
+
// inside its own schema, and uninstall reads that list before it drops
|
|
17
|
+
// anything.
|
|
18
|
+
//
|
|
19
|
+
// Measured on a real Supabase project before any of this was written:
|
|
20
|
+
//
|
|
21
|
+
// pg_net lands in `public` - the customer's own schema - unless it is told
|
|
22
|
+
// where to go, so it is always told.
|
|
23
|
+
// cron.unschedule is overloaded on (bigint) and (name), and an uncast
|
|
24
|
+
// parameter binds to the name one and fails with "could not find valid
|
|
25
|
+
// entry for job", which reads like the job was already gone.
|
|
26
|
+
|
|
27
|
+
const sqlengine = require('./sqlengine.js');
|
|
28
|
+
|
|
29
|
+
// Where the engine lives when it is staying. A fixed name rather than a
|
|
30
|
+
// timestamped one: the throwaway copies are named for the moment they were
|
|
31
|
+
// made so an abandoned one can be aged and swept, and this is the opposite -
|
|
32
|
+
// it is meant to be found again.
|
|
33
|
+
const SCHEMA = 'kryptheon';
|
|
34
|
+
|
|
35
|
+
// Three in the morning, in whatever the database thinks the time is. Late
|
|
36
|
+
// enough to be quiet, early enough that the answer is waiting before anybody
|
|
37
|
+
// looks.
|
|
38
|
+
const AT = '0 3 * * *';
|
|
39
|
+
|
|
40
|
+
const quote = (name) => '"' + String(name).split('"').join('""') + '"';
|
|
41
|
+
|
|
42
|
+
/** Is this extension already here, and did we have to put it here? */
|
|
43
|
+
async function extensionState(client, name) {
|
|
44
|
+
const { rows } = await client.query(
|
|
45
|
+
'SELECT installed_version FROM pg_available_extensions WHERE name = $1', [name],
|
|
46
|
+
);
|
|
47
|
+
if (!rows.length) return { available: false, installed: false };
|
|
48
|
+
return { available: true, installed: Boolean(rows[0].installed_version) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Puts an extension where it belongs, and says whether it had to.
|
|
53
|
+
*
|
|
54
|
+
* `pg_net` installs into whatever the search_path says unless it is told
|
|
55
|
+
* otherwise, and on Supabase that is `public`. Watching it appear in a
|
|
56
|
+
* customer's own schema is what made this function exist rather than a line
|
|
57
|
+
* of SQL.
|
|
58
|
+
*/
|
|
59
|
+
async function ensureExtension(client, name, into) {
|
|
60
|
+
const state = await extensionState(client, name);
|
|
61
|
+
if (!state.available) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
'this database does not offer ' + name + ', so the nightly run cannot be installed. ' +
|
|
64
|
+
'Supabase has it; a plain Postgres may not.',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (state.installed) return { made: false };
|
|
68
|
+
|
|
69
|
+
const haveSchema = into
|
|
70
|
+
? (await client.query('SELECT count(*)::int n FROM pg_namespace WHERE nspname = $1', [into])).rows[0].n > 0
|
|
71
|
+
: false;
|
|
72
|
+
await client.query('CREATE EXTENSION ' + name + (haveSchema ? ' WITH SCHEMA ' + quote(into) : ''));
|
|
73
|
+
return { made: true, into: haveSchema ? into : null };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Installs the engine, schedules the night, and writes down what it made.
|
|
78
|
+
*
|
|
79
|
+
* Safe to run twice: the engine is CREATE OR REPLACE throughout, and the job
|
|
80
|
+
* is replaced rather than added to, so an upgrade is the same operation as an
|
|
81
|
+
* install.
|
|
82
|
+
*/
|
|
83
|
+
async function install(client, options) {
|
|
84
|
+
const opts = options || {};
|
|
85
|
+
const schema = opts.schema || SCHEMA;
|
|
86
|
+
const source = opts.source || 'public';
|
|
87
|
+
const at = opts.at || AT;
|
|
88
|
+
const made = { extensions: [], schema: false, jobid: null };
|
|
89
|
+
|
|
90
|
+
// pg_net first: it is the one that lands in the wrong place if nobody
|
|
91
|
+
// says where, and it is easier to take back out before anything depends
|
|
92
|
+
// on it.
|
|
93
|
+
const net = await ensureExtension(client, 'pg_net', 'extensions');
|
|
94
|
+
if (net.made) made.extensions.push('pg_net');
|
|
95
|
+
|
|
96
|
+
const cron = await ensureExtension(client, 'pg_cron', null);
|
|
97
|
+
if (cron.made) made.extensions.push('pg_cron');
|
|
98
|
+
|
|
99
|
+
const existed = (await client.query(
|
|
100
|
+
'SELECT count(*)::int n FROM pg_namespace WHERE nspname = $1', [schema],
|
|
101
|
+
)).rows[0].n > 0;
|
|
102
|
+
made.schema = !existed;
|
|
103
|
+
|
|
104
|
+
await sqlengine.install(client, schema);
|
|
105
|
+
|
|
106
|
+
// What this install created, kept where uninstall can find it and nowhere
|
|
107
|
+
// else. In the schema it is about to drop, on purpose: if the schema is
|
|
108
|
+
// gone, so is everything this list describes.
|
|
109
|
+
await client.query(
|
|
110
|
+
'CREATE TABLE IF NOT EXISTS ' + quote(schema) + '.installed (' +
|
|
111
|
+
'id integer PRIMARY KEY DEFAULT 1, ' +
|
|
112
|
+
'installed_at timestamptz NOT NULL DEFAULT now(), ' +
|
|
113
|
+
'source text NOT NULL, ' +
|
|
114
|
+
'made_extensions text[] NOT NULL DEFAULT \'{}\', ' +
|
|
115
|
+
'made_schema boolean NOT NULL DEFAULT false, ' +
|
|
116
|
+
'jobid bigint, ' +
|
|
117
|
+
'CONSTRAINT only_one CHECK (id = 1))',
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Replaced rather than added to. cron.schedule with a name that already
|
|
121
|
+
// exists updates it, but an install that ran twice under two names would
|
|
122
|
+
// attack the same app twice a night for ever.
|
|
123
|
+
const { rows } = await client.query(
|
|
124
|
+
'SELECT cron.schedule($1, $2, $3) AS jobid',
|
|
125
|
+
['kryptheon_nightly', at, 'SELECT ' + quote(schema) + '.nightly(' + literal(source) + ')'],
|
|
126
|
+
);
|
|
127
|
+
made.jobid = rows[0].jobid;
|
|
128
|
+
|
|
129
|
+
await client.query(
|
|
130
|
+
'INSERT INTO ' + quote(schema) + '.installed (id, source, made_extensions, made_schema, jobid) ' +
|
|
131
|
+
'VALUES (1, $1, $2, $3, $4) ' +
|
|
132
|
+
'ON CONFLICT (id) DO UPDATE SET source = EXCLUDED.source, jobid = EXCLUDED.jobid, ' +
|
|
133
|
+
'made_extensions = ' + quote(schema) + '.installed.made_extensions || EXCLUDED.made_extensions, ' +
|
|
134
|
+
'installed_at = now()',
|
|
135
|
+
[source, made.extensions, made.schema, made.jobid],
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
return { schema: schema, source: source, at: at, jobid: made.jobid, made: made };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A string, quoted the way Postgres wants it inside another statement. */
|
|
142
|
+
function literal(text) {
|
|
143
|
+
return "'" + String(text).split("'").join("''") + "'";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** What is installed, or null. */
|
|
147
|
+
async function status(client, options) {
|
|
148
|
+
const schema = (options || {}).schema || SCHEMA;
|
|
149
|
+
const here = (await client.query(
|
|
150
|
+
'SELECT count(*)::int n FROM pg_namespace WHERE nspname = $1', [schema],
|
|
151
|
+
)).rows[0].n > 0;
|
|
152
|
+
if (!here) return null;
|
|
153
|
+
|
|
154
|
+
const { rows } = await client.query(
|
|
155
|
+
'SELECT source, installed_at, jobid, made_extensions, made_schema FROM ' +
|
|
156
|
+
quote(schema) + '.installed WHERE id = 1',
|
|
157
|
+
).catch(() => ({ rows: [] }));
|
|
158
|
+
if (!rows.length) return { schema: schema, source: null, jobid: null, unrecorded: true };
|
|
159
|
+
|
|
160
|
+
const job = await client.query(
|
|
161
|
+
'SELECT jobname, schedule, active FROM cron.job WHERE jobid = $1', [rows[0].jobid],
|
|
162
|
+
).catch(() => ({ rows: [] }));
|
|
163
|
+
|
|
164
|
+
return Object.assign({ schema: schema }, rows[0], { job: job.rows[0] || null });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Takes it all away, and only what was put here.
|
|
169
|
+
*
|
|
170
|
+
* The order matters. The job goes first, because a job whose function has
|
|
171
|
+
* been dropped runs every night and fails every night, and pg_cron keeps
|
|
172
|
+
* every one of those failures in a log nobody reads.
|
|
173
|
+
*/
|
|
174
|
+
async function uninstall(client, options) {
|
|
175
|
+
const schema = (options || {}).schema || SCHEMA;
|
|
176
|
+
const removed = { job: false, schema: false, extensions: [] };
|
|
177
|
+
|
|
178
|
+
const was = await status(client, { schema: schema });
|
|
179
|
+
if (!was) return removed;
|
|
180
|
+
|
|
181
|
+
if (was.jobid) {
|
|
182
|
+
// Cast on purpose. cron.unschedule is overloaded on (bigint) and (name),
|
|
183
|
+
// and a parameter sent as text binds to the name overload and goes
|
|
184
|
+
// looking for a job CALLED '1'. It fails with "could not find valid entry
|
|
185
|
+
// for job", which reads exactly like the job having already gone - so the
|
|
186
|
+
// uninstall would have reported success and left the job running.
|
|
187
|
+
await client.query('SELECT cron.unschedule($1::bigint)', [was.jobid]).catch(() => {});
|
|
188
|
+
const left = await client.query(
|
|
189
|
+
'SELECT count(*)::int n FROM cron.job WHERE jobid = $1', [was.jobid],
|
|
190
|
+
).catch(() => ({ rows: [{ n: 0 }] }));
|
|
191
|
+
if (left.rows[0].n) {
|
|
192
|
+
throw new Error('the nightly job ' + was.jobid + ' is still scheduled, so nothing else was removed');
|
|
193
|
+
}
|
|
194
|
+
removed.job = true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (was.made_schema !== false) {
|
|
198
|
+
await client.query('DROP SCHEMA IF EXISTS ' + quote(schema) + ' CASCADE');
|
|
199
|
+
removed.schema = true;
|
|
200
|
+
} else {
|
|
201
|
+
// The schema was somebody else's before we arrived. Take the engine's own
|
|
202
|
+
// things out of it and leave the schema itself where it was.
|
|
203
|
+
await sqlengine.uninstall(client, schema).catch(() => {});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Extensions last, and only ours. A database that had pg_cron before
|
|
207
|
+
// Kryptheon arrived must still have it afterwards - and dropping pg_cron
|
|
208
|
+
// would take every other job in it with it.
|
|
209
|
+
for (const ext of was.made_extensions || []) {
|
|
210
|
+
await client.query('DROP EXTENSION IF EXISTS ' + ext).catch(() => {});
|
|
211
|
+
removed.extensions.push(ext);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return removed;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = {
|
|
218
|
+
SCHEMA: SCHEMA,
|
|
219
|
+
AT: AT,
|
|
220
|
+
install: install,
|
|
221
|
+
uninstall: uninstall,
|
|
222
|
+
status: status,
|
|
223
|
+
extensionState: extensionState,
|
|
224
|
+
};
|
package/intro.js
CHANGED
|
@@ -98,6 +98,57 @@ function consentLines(target) {
|
|
|
98
98
|
];
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* What staying costs, said before anything is left behind.
|
|
103
|
+
*
|
|
104
|
+
* The scan's consent screen above ends "I do not leave anything behind", and
|
|
105
|
+
* that is true of a scan and false of an install. Reusing it here would be
|
|
106
|
+
* the product lying on the one screen it exists to be honest on, so this is
|
|
107
|
+
* its own text and says the opposite plainly, first.
|
|
108
|
+
*
|
|
109
|
+
* Everything that gets left is named, because "it installs a few things" is
|
|
110
|
+
* the sentence somebody agrees to and then finds a `cron` schema they did not
|
|
111
|
+
* recognise. And the way out is on the screen where they say yes, not in a
|
|
112
|
+
* README they will not read again.
|
|
113
|
+
*/
|
|
114
|
+
function installConsentLines(schema, source, at) {
|
|
115
|
+
return [
|
|
116
|
+
'This one stays. Everything else this tool does is temporary; this is not.',
|
|
117
|
+
'',
|
|
118
|
+
'What I will leave in your database:',
|
|
119
|
+
'',
|
|
120
|
+
' - A schema called "' + schema + '", holding the functions that do the',
|
|
121
|
+
' checking. No data of yours goes in it.',
|
|
122
|
+
'',
|
|
123
|
+
' - Two Postgres extensions, if they are not already there: pg_cron, which',
|
|
124
|
+
' runs things on a schedule, and pg_net. Supabase provides both.',
|
|
125
|
+
'',
|
|
126
|
+
' - One scheduled job, named kryptheon_nightly, set to ' + at + '.',
|
|
127
|
+
'',
|
|
128
|
+
'What it will do, every night:',
|
|
129
|
+
'',
|
|
130
|
+
' - Exactly what the scan does now - copy the shape of your "' + source + '"',
|
|
131
|
+
' tables into a temporary space, put two made-up people in it, attack',
|
|
132
|
+
' that, write down what got through, and delete the copy.',
|
|
133
|
+
'',
|
|
134
|
+
' - It still never reads your real data, and still sends nothing anywhere.',
|
|
135
|
+
' Nothing leaves your database, because the checking happens inside it.',
|
|
136
|
+
'',
|
|
137
|
+
'One thing it cannot do:',
|
|
138
|
+
'',
|
|
139
|
+
' - It cannot race two requests against each other, which is how the',
|
|
140
|
+
' "can this exist twice" question is answered. That needs two',
|
|
141
|
+
' connections at the same instant, and nothing living inside a database',
|
|
142
|
+
' has them. The nightly report names every column it could not race.',
|
|
143
|
+
' Running the command by hand still tests them properly.',
|
|
144
|
+
'',
|
|
145
|
+
'To take it all away again: npx kryptheon-night uninstall',
|
|
146
|
+
'',
|
|
147
|
+
'That removes the job, the schema, and any extension I had to add - and',
|
|
148
|
+
'leaves alone anything that was already here.',
|
|
149
|
+
];
|
|
150
|
+
}
|
|
151
|
+
|
|
101
152
|
/** Asks a question and hands back what was typed. */
|
|
102
153
|
function ask(question) {
|
|
103
154
|
return new Promise((resolve) => {
|
|
@@ -191,6 +242,7 @@ async function askForConnection(say) {
|
|
|
191
242
|
module.exports = {
|
|
192
243
|
whereToFindIt: whereToFindIt,
|
|
193
244
|
consentLines: consentLines,
|
|
245
|
+
installConsentLines: installConsentLines,
|
|
194
246
|
ask: ask,
|
|
195
247
|
askSecret: askSecret,
|
|
196
248
|
askYesNo: askYesNo,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kryptheon-night",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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",
|
|
@@ -20,13 +20,16 @@
|
|
|
20
20
|
"intro.js",
|
|
21
21
|
"trouble.js",
|
|
22
22
|
"connect.js",
|
|
23
|
-
"README.md"
|
|
23
|
+
"README.md",
|
|
24
|
+
"installer.js",
|
|
25
|
+
"sqlengine.js",
|
|
26
|
+
"engine.sql"
|
|
24
27
|
],
|
|
25
28
|
"engines": {
|
|
26
29
|
"node": ">=18.0.0"
|
|
27
30
|
},
|
|
28
31
|
"scripts": {
|
|
29
|
-
"check": "node package.check.js && node trouble.check.js && node intro.check.js && node finding.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 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 loop.check.js",
|
|
32
|
+
"check": "node package.check.js && node trouble.check.js && node intro.check.js && node finding.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 loop.check.js",
|
|
30
33
|
"check:dry": "node package.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node recheck.check.js",
|
|
31
34
|
"check:package": "node package.check.js",
|
|
32
35
|
"check:trouble": "node trouble.check.js",
|
|
@@ -52,7 +55,9 @@
|
|
|
52
55
|
"check:interruption": "node orphan.check.js",
|
|
53
56
|
"probe:orphan": "node probe-orphan.js",
|
|
54
57
|
"check:twin": "node twin.check.js",
|
|
55
|
-
"prepublishOnly": "node package.check.js"
|
|
58
|
+
"prepublishOnly": "node package.check.js",
|
|
59
|
+
"check:engines": "node engines.check.js",
|
|
60
|
+
"check:installer": "node installer.check.js"
|
|
56
61
|
},
|
|
57
62
|
"keywords": [
|
|
58
63
|
"supabase",
|
package/scan.js
CHANGED
|
@@ -112,6 +112,35 @@ function suggest(schemas) {
|
|
|
112
112
|
worth.map((entry) => entry.schema + ' (' + entry.tables + ')').join(', ');
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* The engine, as six operations, so the scan does not know which one it has.
|
|
117
|
+
*
|
|
118
|
+
* `scan.js` named `schema.js`, `attack.js`, `tamper.js` and `orphan.js`
|
|
119
|
+
* directly, which meant the SQL engine could only ever be reached by the twin
|
|
120
|
+
* check. Two engines and one of them never on the real path is how the unused
|
|
121
|
+
* one rots however good the comparison is.
|
|
122
|
+
*
|
|
123
|
+
* Deliberately not included: the collision race. It needs two requests in
|
|
124
|
+
* flight at once, which needs two connections, and no credential ever moves
|
|
125
|
+
* into the database - so the SQL engine cannot have it and says so. The scan
|
|
126
|
+
* keeps racing for real down `openSession` whichever engine it is given, and
|
|
127
|
+
* the report says what could not be raced when there is no second connection.
|
|
128
|
+
* That is the one place the two doors are honestly not the same, and it is
|
|
129
|
+
* named rather than papered over.
|
|
130
|
+
*/
|
|
131
|
+
function nodeEngine() {
|
|
132
|
+
return {
|
|
133
|
+
name: 'node',
|
|
134
|
+
readSchema: (client, target) => schema.readSchema(client, target),
|
|
135
|
+
writeSchema: (client, plan, into) => schema.writeSchema(client, plan, into),
|
|
136
|
+
seed: (client, into, tables) => attack.seed(client, into, tables),
|
|
137
|
+
impersonate: (client, into, tables) => attack.impersonate(client, into, tables),
|
|
138
|
+
tamper: (client, into, tables, seeded, policies) =>
|
|
139
|
+
tamper.tamper(client, into, tables, seeded, policies),
|
|
140
|
+
orphan: (client, into, tables, seeded) => orphan.orphan(client, into, tables, seeded),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
115
144
|
/**
|
|
116
145
|
* Runs the whole thing and hands back what got through.
|
|
117
146
|
*
|
|
@@ -122,6 +151,8 @@ async function scan(client, sourceSchema, options) {
|
|
|
122
151
|
const opts = options || {};
|
|
123
152
|
const copyName = 'kn_' + Date.now().toString(36);
|
|
124
153
|
const say = opts.quiet ? () => {} : line;
|
|
154
|
+
// Whichever engine the caller handed over, or the one built in.
|
|
155
|
+
const engine = opts.engine || nodeEngine();
|
|
125
156
|
|
|
126
157
|
// Anything an earlier run could not clean up after itself. The copy is
|
|
127
158
|
// dropped in a `finally`, but a `finally` needs a connection: when the link
|
|
@@ -147,7 +178,7 @@ async function scan(client, sourceSchema, options) {
|
|
|
147
178
|
}
|
|
148
179
|
|
|
149
180
|
say(' Reading the shape of ' + sourceSchema + ' ...');
|
|
150
|
-
const plan = await
|
|
181
|
+
const plan = await engine.readSchema(client, sourceSchema);
|
|
151
182
|
|
|
152
183
|
if (!plan.tables.length) {
|
|
153
184
|
// Nothing wrong with the database, but nothing was tested either, and
|
|
@@ -171,8 +202,8 @@ async function scan(client, sourceSchema, options) {
|
|
|
171
202
|
say(' ' + plan.tables.length + ' tables, ' + plan.policies.length + ' rules. Building a copy ...');
|
|
172
203
|
|
|
173
204
|
try {
|
|
174
|
-
await
|
|
175
|
-
const copyPlan = await
|
|
205
|
+
await engine.writeSchema(client, plan, copyName);
|
|
206
|
+
const copyPlan = await engine.readSchema(client, copyName);
|
|
176
207
|
|
|
177
208
|
// The copy has to be the app, or nothing that follows means anything.
|
|
178
209
|
const differences = schema.diffSchemas(plan, copyPlan);
|
|
@@ -192,7 +223,7 @@ async function scan(client, sourceSchema, options) {
|
|
|
192
223
|
// from every attack, and a table nobody attacks has nothing to report.
|
|
193
224
|
const standIns = new Set((plan.external || []).map((entry) => entry.stub));
|
|
194
225
|
const theirs = copyPlan.tables.filter((table) => !standIns.has(table.name));
|
|
195
|
-
const sown = await
|
|
226
|
+
const sown = await engine.seed(client, copyName, theirs);
|
|
196
227
|
|
|
197
228
|
// Views are read but never seeded: they have no rows of their own, they
|
|
198
229
|
// show the rows of the tables underneath. That is exactly why they matter
|
|
@@ -206,7 +237,7 @@ async function scan(client, sourceSchema, options) {
|
|
|
206
237
|
rlsEnabled: false,
|
|
207
238
|
isView: true,
|
|
208
239
|
}));
|
|
209
|
-
const impersonation = await
|
|
240
|
+
const impersonation = await engine.impersonate(client, copyName, theirs.concat(views));
|
|
210
241
|
|
|
211
242
|
// Every attack genuinely run, named the way a finding is named. The
|
|
212
243
|
// re-check needs this: a finding that disappears because its attack never
|
|
@@ -246,7 +277,7 @@ async function scan(client, sourceSchema, options) {
|
|
|
246
277
|
// The copy's own policies, not the app's - they are the same rules, and
|
|
247
278
|
// the copy is what was actually attacked. The report needs them to say
|
|
248
279
|
// which rule let a write through rather than assuming one.
|
|
249
|
-
const writes = await
|
|
280
|
+
const writes = await engine.tamper(client, copyName, theirs, sown.seeded, copyPlan.policies);
|
|
250
281
|
for (const key of writes.completed) attempted.push(key);
|
|
251
282
|
for (const stuck of writes.blocked) {
|
|
252
283
|
notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
|
|
@@ -254,7 +285,7 @@ async function scan(client, sourceSchema, options) {
|
|
|
254
285
|
|
|
255
286
|
// Can a half-finished write survive? Rolled back like the writes above.
|
|
256
287
|
say(' Looking for rows that could point at nothing ...');
|
|
257
|
-
const stranded = await
|
|
288
|
+
const stranded = await engine.orphan(client, copyName, theirs, sown.seeded);
|
|
258
289
|
for (const key of stranded.completed) attempted.push(key);
|
|
259
290
|
for (const missed of stranded.notTried) {
|
|
260
291
|
notChecked.push({
|
package/schema.js
CHANGED
|
@@ -530,10 +530,23 @@ function qualifyOwnRefs(definition, plan, target) {
|
|
|
530
530
|
// A replacer function, never a replacement string: `$&` and friends are read
|
|
531
531
|
// as instructions inside one, and that has already cost this project an
|
|
532
532
|
// engine that would not install.
|
|
533
|
+
// A quoted identifier can hold anything, including spaces and quotes of its
|
|
534
|
+
// own doubled up, so it is matched as a whole rather than as a run of safe
|
|
535
|
+
// characters. The first attempt used [^".\s(]+ inside the quotes and
|
|
536
|
+
// silently did not match `REFERENCES "Group Table"(...)` - which the twin
|
|
537
|
+
// fixture has, because it was built out of every shape that has ever been
|
|
538
|
+
// read wrong, and it caught this the first time it ran.
|
|
539
|
+
//
|
|
540
|
+
// The unquoted alternative excludes a dot, so a name that is already
|
|
541
|
+
// schema-qualified is left alone: that is the other rewrite's work.
|
|
533
542
|
return String(definition).replace(
|
|
534
|
-
/(\bREFERENCES\s+)("
|
|
535
|
-
(whole, before,
|
|
536
|
-
|
|
543
|
+
/(\bREFERENCES\s+)("(?:[^"]|"")*"|[^\s(".]+)(\s*\()/gi,
|
|
544
|
+
(whole, before, written, after) => {
|
|
545
|
+
const name = written.charAt(0) === '"'
|
|
546
|
+
? written.slice(1, -1).split('""').join('"')
|
|
547
|
+
: written;
|
|
548
|
+
return own.has(name) ? before + quote(target) + '.' + quote(name) + after : whole;
|
|
549
|
+
},
|
|
537
550
|
);
|
|
538
551
|
}
|
|
539
552
|
|
|
@@ -809,8 +822,37 @@ async function writeSchema(client, plan, target) {
|
|
|
809
822
|
}
|
|
810
823
|
|
|
811
824
|
mustStayInside(statements, target);
|
|
812
|
-
|
|
813
|
-
|
|
825
|
+
// Built with the copy first on the search_path, so a bare name binds to the
|
|
826
|
+
// copy and not to whatever the customer happens to have.
|
|
827
|
+
//
|
|
828
|
+
// This is the structural half, and it went in after chasing the same bug
|
|
829
|
+
// through three different kinds of expression. Postgres writes a name
|
|
830
|
+
// without its schema whenever that name is already reachable - so a real app
|
|
831
|
+
// in `public` hands back "REFERENCES profiles(id)" and
|
|
832
|
+
// "nextval('orders_id_seq'::regclass)", and a rewrite that goes looking for
|
|
833
|
+
// a schema name finds nothing to change in either. Replayed into the copy,
|
|
834
|
+
// both bound to the customer's own objects: the foreign keys pointed at
|
|
835
|
+
// their tables, and the copy drew its keys from their sequences, which
|
|
836
|
+
// advanced them.
|
|
837
|
+
//
|
|
838
|
+
// Rewriting each kind of expression in turn is a game with no end - index
|
|
839
|
+
// predicates, checks that call a function, view bodies. Naming the copy
|
|
840
|
+
// first on the path ends all of them at once, because it changes what a
|
|
841
|
+
// bare name means rather than trying to find every place one can appear.
|
|
842
|
+
//
|
|
843
|
+
// `public` stays on the path, after the copy, because the copy legitimately
|
|
844
|
+
// needs what lives there - gen_random_uuid() and the like. `extensions` is
|
|
845
|
+
// where Supabase keeps them; a schema on the path that does not exist is
|
|
846
|
+
// ignored rather than an error.
|
|
847
|
+
const { rows: pathRows } = await client.query('SHOW search_path');
|
|
848
|
+
const restoreTo = pathRows[0].search_path;
|
|
849
|
+
await client.query('SET search_path TO ' + quote(target) + ', public, extensions');
|
|
850
|
+
try {
|
|
851
|
+
for (const statement of statements) {
|
|
852
|
+
await client.query(statement);
|
|
853
|
+
}
|
|
854
|
+
} finally {
|
|
855
|
+
await client.query('SET search_path TO ' + restoreTo).catch(() => {});
|
|
814
856
|
}
|
|
815
857
|
|
|
816
858
|
// And then look at what was actually built, rather than at what was meant.
|
|
@@ -825,20 +867,41 @@ async function writeSchema(client, plan, target) {
|
|
|
825
867
|
// Nothing is written to a table outside the copy either way. The damage is
|
|
826
868
|
// subtler than that: a copy tied to the customer's rows is not the app being
|
|
827
869
|
// attacked, and every verdict taken from it is about something else.
|
|
870
|
+
// Foreign keys AND column defaults, because the second one is where this
|
|
871
|
+
// hid after the first was closed: a serial column's default is
|
|
872
|
+
// nextval('<sequence>'), and the copy was calling the customer's. Nothing
|
|
873
|
+
// was written to their tables, but nextval advances a sequence, so their
|
|
874
|
+
// database changed - and "we never touch your live app" says at all.
|
|
875
|
+
//
|
|
876
|
+
// Asked of pg_depend rather than of the text, so it holds for any
|
|
877
|
+
// expression that ends up pointing at a relation, not only the ones anybody
|
|
878
|
+
// thought to look at. pg_catalog is excluded because everything depends on
|
|
879
|
+
// it; anything else outside the copy is the bug.
|
|
828
880
|
const { rows: strays } = await client.query(
|
|
829
|
-
`SELECT con.conname, rn.nspname AS points_at
|
|
881
|
+
`SELECT con.conname AS what, rn.nspname AS points_at
|
|
830
882
|
FROM pg_constraint con
|
|
831
883
|
JOIN pg_class cl ON cl.oid = con.conrelid
|
|
832
884
|
JOIN pg_namespace cn ON cn.oid = cl.relnamespace
|
|
833
885
|
JOIN pg_class rc ON rc.oid = con.confrelid
|
|
834
886
|
JOIN pg_namespace rn ON rn.oid = rc.relnamespace
|
|
835
|
-
WHERE con.contype = 'f' AND cn.nspname = $1 AND rn.nspname <> $1
|
|
887
|
+
WHERE con.contype = 'f' AND cn.nspname = $1 AND rn.nspname <> $1
|
|
888
|
+
UNION ALL
|
|
889
|
+
SELECT cl.relname || '.' || a.attname || ' default' AS what,
|
|
890
|
+
rn.nspname || '.' || rc.relname AS points_at
|
|
891
|
+
FROM pg_depend d
|
|
892
|
+
JOIN pg_attrdef ad ON ad.oid = d.objid AND d.classid = 'pg_attrdef'::regclass
|
|
893
|
+
JOIN pg_class cl ON cl.oid = ad.adrelid
|
|
894
|
+
JOIN pg_namespace cn ON cn.oid = cl.relnamespace
|
|
895
|
+
JOIN pg_attribute a ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
|
|
896
|
+
JOIN pg_class rc ON rc.oid = d.refobjid AND d.refclassid = 'pg_class'::regclass
|
|
897
|
+
JOIN pg_namespace rn ON rn.oid = rc.relnamespace
|
|
898
|
+
WHERE cn.nspname = $1 AND rn.nspname <> $1 AND rn.nspname <> 'pg_catalog'`,
|
|
836
899
|
[target],
|
|
837
900
|
);
|
|
838
901
|
if (strays.length) {
|
|
839
902
|
throw new Error(
|
|
840
903
|
'the copy points outside itself, so it is not the app: ' +
|
|
841
|
-
strays.map((row) => row.
|
|
904
|
+
strays.map((row) => row.what + ' -> ' + row.points_at).join(', '),
|
|
842
905
|
);
|
|
843
906
|
}
|
|
844
907
|
|
|
@@ -987,6 +1050,7 @@ module.exports = {
|
|
|
987
1050
|
readExternalTargets: readExternalTargets,
|
|
988
1051
|
referenceIn: referenceIn,
|
|
989
1052
|
rewriteSchemaRefs: rewriteSchemaRefs,
|
|
1053
|
+
qualifyOwnRefs: qualifyOwnRefs,
|
|
990
1054
|
stubNameFor: stubNameFor,
|
|
991
1055
|
IDENTITIES: IDENTITIES,
|
|
992
1056
|
quote: quote,
|