kryptheon-night 0.1.0

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/intro.js ADDED
@@ -0,0 +1,202 @@
1
+ // What the person is asked, and what they are told before anything runs.
2
+ //
3
+ // This file is the whole difference between a tool for people who write code
4
+ // and a tool for people who do not. The engine behind it does not change; the
5
+ // first ninety seconds do.
6
+ //
7
+ // Two things happen here and both are deliberate.
8
+ //
9
+ // Asking, rather than requiring. `KN_DATABASE_URL=... node scan.js public` is
10
+ // three unfamiliar ideas at once: an environment variable, a connection
11
+ // string, and a schema. Somebody who has only ever clicked Deploy has met
12
+ // none of them. So the command takes no arguments, and asks.
13
+ //
14
+ // Telling before doing. This tool connects to a person's production database
15
+ // with the owner's credential and creates things in it. Nobody should agree
16
+ // to that from a package name. What it does, what it does not do, and then a
17
+ // yes or no - in that order, before the first statement is sent.
18
+
19
+ const readline = require('readline');
20
+ const trouble = require('./trouble.js');
21
+
22
+ /**
23
+ * Where the connection string is, said as buttons rather than as concepts.
24
+ *
25
+ * Named screen by screen because "get your connection string" is not an
26
+ * instruction to somebody who does not know the phrase. Supabase first
27
+ * because that is who this is for; Lovable and Bolt build on Supabase, so
28
+ * their users end up on the same page by a different door.
29
+ */
30
+ function whereToFindIt() {
31
+ return [
32
+ 'Where to find it:',
33
+ '',
34
+ ' Supabase supabase.com/dashboard -> your project -> the gear icon',
35
+ ' (Project Settings) at the bottom left -> Database ->',
36
+ ' scroll to "Connection string" -> the URI tab -> Copy.',
37
+ '',
38
+ ' Replace [YOUR-PASSWORD] in it with your database password.',
39
+ ' It is on that same page under "Database password".',
40
+ '',
41
+ ' Lovable your app uses Supabase underneath. Open the Supabase',
42
+ ' and Bolt project it made for you and follow the lines above.',
43
+ '',
44
+ ' Neon console.neon.tech -> your project -> Connection Details.',
45
+ '',
46
+ 'It is one long line beginning postgresql:// and it ends in /postgres.',
47
+ ];
48
+ }
49
+
50
+ /**
51
+ * What is about to happen, and what is not.
52
+ *
53
+ * The second half matters more than the first. Everybody who runs this is
54
+ * being asked to hand a stranger's program the key to their live database,
55
+ * and the fears they have are specific: will you read my customers, will you
56
+ * send anything anywhere, will you leave a mess. Each of those is answered in
57
+ * its own line, in the words they would use.
58
+ *
59
+ * Nothing here is a promise made only on this screen. Every line is something
60
+ * the code is built to make true and a check in this repo fails if it stops
61
+ * being true - `untouched.check.js` photographs the whole database and
62
+ * compares it afterwards.
63
+ */
64
+ function consentLines(target) {
65
+ return [
66
+ 'Before I touch anything, here is exactly what I am going to do.',
67
+ '',
68
+ 'What I will do:',
69
+ '',
70
+ ' - Look at the shape of your "' + target + '" tables: their names, their',
71
+ ' columns, and the rules about who is allowed to see what.',
72
+ '',
73
+ ' - Make one new temporary space inside your database and rebuild that',
74
+ ' same shape in it. Your real tables are not changed.',
75
+ '',
76
+ ' - Put two made-up people in the copy - fake names, fake emails.',
77
+ '',
78
+ ' - Attack the copy. I try to read those two fake people as a stranger',
79
+ ' would, try to change their rows, try to break them.',
80
+ '',
81
+ ' - Tell you what got through, and delete the copy.',
82
+ '',
83
+ 'What I will not do:',
84
+ '',
85
+ ' - I do not read your real data. Not one customer, order or message.',
86
+ ' Every row I read is a row I put there myself a moment earlier.',
87
+ '',
88
+ ' - I do not change your real tables. Only the temporary copy.',
89
+ '',
90
+ ' - I do not send anything anywhere. No account, no upload, no server of',
91
+ ' mine. Your connection string stays on this computer and is not',
92
+ ' written to any file.',
93
+ '',
94
+ ' - I do not leave anything behind. The copy is deleted when I finish,',
95
+ ' and also if I crash.',
96
+ '',
97
+ 'This takes two or three minutes.',
98
+ ];
99
+ }
100
+
101
+ /** Asks a question and hands back what was typed. */
102
+ function ask(question) {
103
+ return new Promise((resolve) => {
104
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
105
+ rl.question(question, (answer) => {
106
+ rl.close();
107
+ resolve(String(answer || '').trim());
108
+ });
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Asks for something that must not be echoed.
114
+ *
115
+ * The connection string carries the password to the entire database. Shown on
116
+ * screen it is read by whoever is behind them, and it stays in the scrollback
117
+ * of whatever terminal they are in for as long as that window is open.
118
+ *
119
+ * Nothing is printed as they type - not even stars, because a pasted string
120
+ * of stars is no more checkable than nothing and the character count alone
121
+ * says how long the password is. What they get instead is the address read
122
+ * back to them afterwards, with the password removed, which is the part they
123
+ * would actually want to check.
124
+ */
125
+ function askSecret(question) {
126
+ return new Promise((resolve) => {
127
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
128
+ let silent = false;
129
+ // readline writes every keystroke back to the screen itself. This replaces
130
+ // that with nothing, but only once the prompt has been written - otherwise
131
+ // the question never appears either.
132
+ rl._writeToOutput = function (text) {
133
+ if (!silent) rl.output.write(text);
134
+ };
135
+ rl.question(question, (answer) => {
136
+ silent = false;
137
+ rl.output.write('\n');
138
+ rl.close();
139
+ resolve(String(answer || '').trim());
140
+ });
141
+ silent = true;
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Was that a yes?
147
+ *
148
+ * The question being answered is "may I connect to your production database",
149
+ * so the rule is that only a yes is a yes. A stray newline, a shrug, an
150
+ * accidental Enter on an empty line - none of those are permission, and the
151
+ * cost of getting this backwards is connecting to somebody's live database
152
+ * without being asked to.
153
+ *
154
+ * Separate from the asking so it can be checked directly. Every form of this
155
+ * that hid inside a prompt went unchecked.
156
+ */
157
+ function isYes(answer) {
158
+ const said = String(answer == null ? '' : answer).trim().toLowerCase();
159
+ return said === 'y' || said === 'yes';
160
+ }
161
+
162
+ /** Yes or no, where anything that is not a clear yes is a no. */
163
+ async function askYesNo(question) {
164
+ return isYes(await ask(question));
165
+ }
166
+
167
+ /**
168
+ * The connection string, from wherever it can be had.
169
+ *
170
+ * The environment variable is checked first so that anybody scripting this
171
+ * never sees a prompt, and so the string does not have to be retyped on every
172
+ * run. Nothing is saved: asked again next time is the correct behaviour for a
173
+ * credential this powerful, and a file holding it would be the one thing this
174
+ * tool tells people not to do.
175
+ */
176
+ async function askForConnection(say) {
177
+ say('');
178
+ say(' I need the connection string for your database.');
179
+ say('');
180
+ say(' It is one line that lets me connect. It contains your database');
181
+ say(' password, so I will not show it on screen as you paste it, and I do');
182
+ say(' not save it anywhere.');
183
+ say('');
184
+ whereToFindIt().forEach((l) => say(' ' + l));
185
+ say('');
186
+
187
+ const given = await askSecret(' Paste it here and press Enter: ');
188
+ return given;
189
+ }
190
+
191
+ module.exports = {
192
+ whereToFindIt: whereToFindIt,
193
+ consentLines: consentLines,
194
+ ask: ask,
195
+ askSecret: askSecret,
196
+ askYesNo: askYesNo,
197
+ isYes: isYes,
198
+ askForConnection: askForConnection,
199
+ // Re-exported so the command has one place to reach for the wording of a
200
+ // failure, rather than two.
201
+ withoutSecret: trouble.withoutSecret,
202
+ };
package/orphan.js ADDED
@@ -0,0 +1,230 @@
1
+ // The interruption attack: can a half-finished write survive?
2
+ //
3
+ // A request cut off partway is mostly a question about the app's code - were
4
+ // the two inserts wrapped in a transaction? - and this tool never sees the
5
+ // app's code. Guessing would be the lost update all over again.
6
+ //
7
+ // But there is a half of it the database answers on its own. A foreign key is
8
+ // what makes a half-finished state impossible to keep, no matter how badly the
9
+ // app behaves or where the connection drops. Without one, an order can name a
10
+ // customer who does not exist, and nothing will ever notice.
11
+ //
12
+ // So the attack is: put in a row pointing at something that is not there, and
13
+ // see whether the database takes it.
14
+ //
15
+ // WHAT THAT ACTUALLY COSTS SOMEBODY (measured in probe-orphan.js)
16
+ //
17
+ // - with no foreign key the row is accepted, and nothing in the database
18
+ // will ever find it again
19
+ // - with one, the insert is refused
20
+ // - with one, deleting the parent is refused too - so "delete my account"
21
+ // really does take the data with it, or fails loudly. Without one, the
22
+ // customer is gone and their rows are still sitting there.
23
+ //
24
+ // WHAT IS DELIBERATELY NOT REPORTED
25
+ //
26
+ // A column with no table it could point at. `stripe_id`, `session_id`,
27
+ // `external_id` reference something outside this database entirely, and
28
+ // telling somebody to add a foreign key to one would be telling them to break
29
+ // their app. Only a column whose name matches a real table here, whose type
30
+ // matches that table's key, is ever considered.
31
+ //
32
+ // Log and audit tables are left alone too. Keeping the id of something that
33
+ // has since been deleted is the entire point of an audit row, and a foreign
34
+ // key there would be the bug.
35
+
36
+ const { quote } = require('./schema.js');
37
+ const attack = require('./attack.js');
38
+
39
+ // Tables whose job is to remember things after they are gone. A dangling id in
40
+ // an audit row is the feature.
41
+ const KEEPS_HISTORY = /(^|_)(log|logs|audit|audits|event|events|history|archive|archives|snapshot|snapshots|activity|activities)(_|$)/i;
42
+
43
+ /** The single-column primary key of a table, or null if it has none. */
44
+ function primaryKeyOf(table) {
45
+ for (const constraint of table.constraints || []) {
46
+ if (constraint.kind !== 'p') continue;
47
+ const match = /PRIMARY KEY \(([^)]+)\)/i.exec(constraint.definition);
48
+ if (!match) continue;
49
+ const columns = match[1].split(',').map((name) => name.trim().split('"').join(''));
50
+ // A composite key is not something a single `<thing>_id` column points at.
51
+ return columns.length === 1 ? columns[0] : null;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * The table a column named `<thing>_id` is pointing at, if there is one.
58
+ *
59
+ * The name has to match a table that is really here, and the types have to
60
+ * agree. Both, because `stripe_id` matches nothing and `org_id integer` does
61
+ * not point at an `orgs.id` that is a uuid.
62
+ */
63
+ function parentFor(column, tables) {
64
+ const stem = /^(.+)_id$/i.exec(column.name);
65
+ if (!stem) return null;
66
+ const wanted = stem[1].toLowerCase();
67
+ const parent = (tables || []).find((table) => {
68
+ const name = table.name.toLowerCase();
69
+ return name === wanted || name === wanted + 's' || name === wanted + 'es';
70
+ });
71
+ if (!parent) return null;
72
+
73
+ const key = primaryKeyOf(parent);
74
+ if (!key) return null;
75
+ const keyColumn = (parent.columns || []).find((c) => c.name === key);
76
+ if (!keyColumn || keyColumn.type !== column.type) return null;
77
+
78
+ return { table: parent, keyColumn: key, type: keyColumn.type };
79
+ }
80
+
81
+ /** Is this column already held down by a foreign key? */
82
+ function alreadyTied(table, columnName) {
83
+ return attack.foreignKeys(table).some((key) => key.columns.includes(columnName));
84
+ }
85
+
86
+ /**
87
+ * Every column that looks like it points somewhere, with whether it is already
88
+ * tied down.
89
+ *
90
+ * Tied columns are attacked too, the same way the collision attack races
91
+ * columns that already have a unique index. Skipping them would mean that the
92
+ * moment somebody adds the foreign key this asked for, the attack stops
93
+ * running - and the re-check could no longer watch it be refused, so it would
94
+ * report the fix it requested as "could not confirm".
95
+ *
96
+ * `tied` decides what is reported, never what is attempted.
97
+ */
98
+ function candidates(tables) {
99
+ const found = [];
100
+ for (const table of tables || []) {
101
+ if (KEEPS_HISTORY.test(table.name)) continue;
102
+ for (const column of table.columns || []) {
103
+ const parent = parentFor(column, tables);
104
+ if (!parent) continue;
105
+ found.push({
106
+ table: table.name,
107
+ column: column.name,
108
+ parent: parent.table.name,
109
+ parentKey: parent.keyColumn,
110
+ type: parent.type,
111
+ tied: alreadyTied(table, column.name),
112
+ });
113
+ }
114
+ }
115
+ return found;
116
+ }
117
+
118
+ /** A value of the right type that is certainly not in the parent table. */
119
+ function nobody(type) {
120
+ const kind = String(type).toLowerCase();
121
+ if (kind === 'uuid') return '99999999-9999-4999-8999-999999999999';
122
+ if (/^(integer|bigint|smallint)/.test(kind)) return 2147480000;
123
+ if (/^(numeric|decimal|real|double)/.test(kind)) return 2147480000;
124
+ return 'kryptheon-nobody';
125
+ }
126
+
127
+ /**
128
+ * Point a row at something that is not there, and see if it is taken.
129
+ *
130
+ * Run as the owner of the schema on purpose. The question is not who is
131
+ * allowed to create an orphan - it is whether the database permits one to
132
+ * exist at all, which is what decides whether a dropped connection can leave
133
+ * one behind.
134
+ *
135
+ * Rolled back, always. Nothing this does survives the statement that did it.
136
+ */
137
+ async function orphan(client, schema, tables, seeded) {
138
+ const findings = [];
139
+ const completed = [];
140
+ const notTried = [];
141
+ const shapeFor = new Map((seeded || []).map((entry) => [entry.table, entry.attempt || 0]));
142
+ const byName = new Map((tables || []).map((table) => [table.name, table]));
143
+
144
+ for (const target of candidates(tables)) {
145
+ const key = 'orphaned:' + target.table + ':' + target.column;
146
+ const table = byName.get(target.table);
147
+ const missing = nobody(target.type);
148
+
149
+ // It only proves anything if the value really is absent from the parent.
150
+ try {
151
+ const { rows } = await client.query(
152
+ 'SELECT 1 FROM ' + quote(schema) + '.' + quote(target.parent) +
153
+ ' WHERE ' + quote(target.parentKey) + ' = $1 LIMIT 1',
154
+ [missing],
155
+ );
156
+ if (rows.length) {
157
+ notTried.push({ table: target.table, column: target.column, why: 'the test value was already in ' + target.parent });
158
+ continue;
159
+ }
160
+ } catch (err) {
161
+ notTried.push({ table: target.table, column: target.column, why: 'could not look in ' + target.parent + ': ' + err.message });
162
+ continue;
163
+ }
164
+
165
+ const override = {};
166
+ override[target.column] = missing;
167
+ let row;
168
+ try {
169
+ row = await attack.rowFor(client, schema, table, attack.USER_C, 9, override, shapeFor.get(target.table));
170
+ } catch (err) {
171
+ notTried.push({ table: target.table, column: target.column, why: err.message });
172
+ continue;
173
+ }
174
+
175
+ await client.query('BEGIN');
176
+ let landed = false;
177
+ let refused = null;
178
+ try {
179
+ await client.query("SET LOCAL statement_timeout = '15s'");
180
+ await attack.insertRow(client, schema, target.table, row);
181
+ landed = true;
182
+ } catch (err) {
183
+ refused = err.message;
184
+ } finally {
185
+ await client.query('ROLLBACK').catch(() => {});
186
+ }
187
+
188
+ if (!landed && !/violates foreign key constraint/i.test(String(refused))) {
189
+ // Turned away by something other than referential integrity, so nothing
190
+ // was learned about whether an orphan can exist.
191
+ notTried.push({ table: target.table, column: target.column, why: refused });
192
+ continue;
193
+ }
194
+
195
+ completed.push(key);
196
+ // Whether a key is supposedly there does not come into it. What is reported
197
+ // is what landed.
198
+ //
199
+ // There was a `&& !target.tied` here, on the reasoning that a tied column
200
+ // could not produce an orphan anyway. It could not - measured on Neon, a
201
+ // foreign key cannot be quietly switched off: DISABLE TRIGGER ALL is
202
+ // refused because the constraint triggers are system triggers, and DISABLE
203
+ // TRIGGER USER does not touch them. So the guard never fired, and a guard
204
+ // that never fires is one more thing that can be wrong. If a row naming
205
+ // nobody ever does land on a column with a key on it, that is worse news
206
+ // than usual and saying so is right.
207
+ if (landed) {
208
+ findings.push({
209
+ kind: 'orphaned',
210
+ table: target.table,
211
+ column: target.column,
212
+ parent: target.parent,
213
+ parentKey: target.parentKey,
214
+ columns: (table.columns || []).map((c) => c.name),
215
+ });
216
+ }
217
+ }
218
+
219
+ return { findings: findings, completed: completed, notTried: notTried };
220
+ }
221
+
222
+ module.exports = {
223
+ KEEPS_HISTORY: KEEPS_HISTORY,
224
+ primaryKeyOf: primaryKeyOf,
225
+ parentFor: parentFor,
226
+ alreadyTied: alreadyTied,
227
+ candidates: candidates,
228
+ nobody: nobody,
229
+ orphan: orphan,
230
+ };
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "kryptheon-night",
3
+ "version": "0.1.0",
4
+ "description": "Attacks a copy of your Supabase database and tells you in plain English what got in.",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "kryptheon-night": "bin/kryptheon-night.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "scan.js",
13
+ "schema.js",
14
+ "attack.js",
15
+ "finding.js",
16
+ "collision.js",
17
+ "tamper.js",
18
+ "orphan.js",
19
+ "recheck.js",
20
+ "intro.js",
21
+ "trouble.js",
22
+ "connect.js",
23
+ "README.md"
24
+ ],
25
+ "engines": {
26
+ "node": ">=18.0.0"
27
+ },
28
+ "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",
30
+ "check:dry": "node package.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node recheck.check.js",
31
+ "check:package": "node package.check.js",
32
+ "check:trouble": "node trouble.check.js",
33
+ "check:intro": "node intro.check.js",
34
+ "check:report": "node finding.check.js",
35
+ "check:recheck": "node recheck.check.js",
36
+ "check:copy": "node schema.check.js",
37
+ "check:collision": "node collision.check.js",
38
+ "check:loop": "node loop.check.js",
39
+ "probe": "node probe-rls.js",
40
+ "probe:collision": "node probe-collision.js",
41
+ "demo": "node demo.js",
42
+ "check:promise": "node untouched.check.js",
43
+ "check:blocked": "node blocked.check.js",
44
+ "check:external": "node external.check.js",
45
+ "check:shapes": "node shapes.check.js",
46
+ "check:verdicts": "node verdicts.check.js",
47
+ "check:writes": "node tamper.check.js",
48
+ "check:orphans": "node orphans.check.js",
49
+ "probe:write": "node probe-write.js",
50
+ "check:usable": "node usable.check.js",
51
+ "check:guests": "node guests.check.js",
52
+ "check:interruption": "node orphan.check.js",
53
+ "probe:orphan": "node probe-orphan.js",
54
+ "check:twin": "node twin.check.js",
55
+ "prepublishOnly": "node package.check.js"
56
+ },
57
+ "keywords": [
58
+ "supabase",
59
+ "postgres",
60
+ "rls",
61
+ "row-level-security",
62
+ "security",
63
+ "lovable",
64
+ "cli"
65
+ ],
66
+ "repository": {
67
+ "type": "git",
68
+ "url": "git+https://github.com/jhashantanu987-beep/kryptheon-night.git"
69
+ },
70
+ "dependencies": {
71
+ "pg": "^8.23.0"
72
+ }
73
+ }