kryptheon-night 0.1.39 → 0.1.41

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.
Files changed (11) hide show
  1. package/attack.js +1028 -1025
  2. package/bin/kryptheon-night.js +736 -725
  3. package/engine.sql +5001 -4430
  4. package/finding.js +1804 -1612
  5. package/intro.js +311 -303
  6. package/package.json +3 -3
  7. package/races.js +304 -0
  8. package/recheck.js +4 -0
  9. package/scan.js +1336 -1311
  10. package/schema.js +1588 -1555
  11. package/tamper.js +949 -772
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon-night",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
4
4
  "description": "Attacks a copy of your Supabase database and tells you in plain English what got in.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "commonjs",
@@ -16,7 +16,7 @@
16
16
  "outside.js",
17
17
  "collision.js",
18
18
  "tamper.js",
19
- "orphan.js",
19
+ "orphan.js", "races.js",
20
20
  "recheck.js",
21
21
  "intro.js",
22
22
  "trouble.js",
@@ -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 helpers.check.js && node deeploop.check.js && node teams.check.js && node roles.check.js && node buckets.check.js && node cli.check.js && node loop.check.js && node report.check.js && node installscript.check.js && node tenants.check.js && node takeover.check.js && node elevated.check.js && node checkfit.check.js && node declared.check.js && node identity.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 roles.check.js && node buckets.check.js && node cli.check.js && node loop.check.js && node report.check.js && node installscript.check.js && node tenants.check.js && node takeover.check.js && node elevated.check.js && node checkfit.check.js && node declared.check.js && node races.check.js && node identity.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",
package/races.js ADDED
@@ -0,0 +1,304 @@
1
+ // Races, read from the app's own functions.
2
+ //
3
+ // collision.js explains why a lost update cannot be reported from the schema:
4
+ // whether two requests at once both win depends on code the tool never sees.
5
+ // On Supabase that code is very often in the database - an RPC such as
6
+ // reserve_stock() or create_ticket() - and its text is in the catalogue. So
7
+ // this reads it, and says what the text shows: a step that looks something up
8
+ // and then writes on the strength of it, with nothing that makes a second
9
+ // caller wait or fail. Nothing is run. Every finding is "to check".
10
+ //
11
+ // Three shapes, each missed by a blind test (rounds 11, 15 and 16):
12
+ //
13
+ // number SELECT max(n) + 1 INTO v FROM t, then INSERT INTO t with it - or
14
+ // hand it back for the app to insert - and no unique rule on n:
15
+ // two callers take the same number.
16
+ // (create_ticket, create_work_order, reserve_subscription_code)
17
+ // overdraw SELECT a quantity INTO a variable, RAISE when it is too low,
18
+ // then UPDATE it: two callers both pass the check. (reserve_stock)
19
+ // double look for a row (IF EXISTS, or SELECT ... INTO and an IF on it),
20
+ // then INSERT one, and no unique rule on what was looked up: two
21
+ // callers both find nothing and both insert.
22
+ // (assign_asset, record_receipt)
23
+ //
24
+ // A shape is safe, and only counted as examined, when:
25
+ // - a unique key or unique index covers the columns it depends on (the
26
+ // second caller then fails instead of slipping through);
27
+ // - the INSERT has ON CONFLICT;
28
+ // - for overdraw, the read is FOR UPDATE / FOR SHARE, the UPDATE tests the
29
+ // column again in its WHERE, or a CHECK holds it (see below);
30
+ // - a row lock comes earlier in the function, or it takes an advisory lock,
31
+ // LOCK TABLE or SERIALIZABLE anywhere.
32
+ //
33
+ // Mirrored in engine.sql race_shapes.
34
+
35
+ const LOCKS = /\b(pg_advisory_xact_lock|pg_advisory_lock|pg_try_advisory_xact_lock|pg_try_advisory_lock|lock\s+table|isolation\s+level\s+serializable)\b/;
36
+ const ROW_LOCK = /\bfor\s+(update|no\s+key\s+update|share|key\s+share)\b/;
37
+ // A name as written, quoted or not: "public"."tickets" as well as public.tickets.
38
+ const NAME = '"?[a-z_][a-z0-9_$."]*';
39
+
40
+ /** The body as statements: comments and string contents gone, lower case,
41
+ * one space, split on ";", and a leading BEGIN / END IF / ELSE dropped so a
42
+ * statement starts with what it does. */
43
+ function statementsOf(def) {
44
+ const text = String(def || '')
45
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
46
+ .replace(/--[^\n]*/g, ' ')
47
+ .replace(/'(?:[^']|'')*'/g, "''")
48
+ .toLowerCase()
49
+ .replace(/\s+/g, ' ')
50
+ // Only the body: "create function ... as $function$" and the closing
51
+ // $function$ off. Left on, a function with no DECLARE had its first IF
52
+ // glued to the header and never read as an IF.
53
+ .replace(/^[\s\S]*?\bas\s+\$[a-z0-9_]*\$\s*/, '')
54
+ .replace(/\s*\$[a-z0-9_]*\$\s*$/, '');
55
+ return text.split(';')
56
+ .map((x) => x.trim().replace(/^(?:(?:begin|else|then|loop|end\s+if|end\s+loop)\s+)+/, ''))
57
+ .filter(Boolean);
58
+ }
59
+
60
+ /** A name as written - public.t, "t", x.col - reduced to its last part. */
61
+ function bare(name) {
62
+ const parts = String(name || '').replace(/"/g, '').split('.');
63
+ return parts[parts.length - 1];
64
+ }
65
+
66
+ /** Identifiers in a piece of SQL, each reduced to its last part. */
67
+ function identifiers(text) {
68
+ return (String(text || '').match(/[a-z_][a-z0-9_$."]*/g) || []).map(bare);
69
+ }
70
+
71
+ function mentions(text, names) {
72
+ return names.some((v) => new RegExp('(^|[^a-z0-9_$])' + v.replace(/\$/g, '\\$') + '($|[^a-z0-9_$])').test(text));
73
+ }
74
+
75
+ /** The text up to the bracket that closes the one already open. */
76
+ function untilClose(text) {
77
+ let depth = 0;
78
+ for (let i = 0; i < text.length; i++) {
79
+ if (text[i] === '(') depth++;
80
+ else if (text[i] === ')') { if (depth === 0) return text.slice(0, i); depth--; }
81
+ }
82
+ return text;
83
+ }
84
+
85
+ /** Column sets a second caller cannot repeat: primary keys, uniques, unique indexes. */
86
+ function uniqueSetsOf(plan, table) {
87
+ const sets = [];
88
+ const t = (plan.tables || []).find((x) => x.name === table);
89
+ for (const con of (t && t.constraints) || []) {
90
+ if (con.kind !== 'u' && con.kind !== 'p') continue;
91
+ const inner = /\(([^)]*)\)/.exec(con.definition || '');
92
+ if (inner) sets.push(identifiers(inner[1].toLowerCase()));
93
+ }
94
+ for (const idx of plan.indexes || []) {
95
+ if (idx.table_name !== table) continue;
96
+ // CREATE UNIQUE INDEX i ON public.t USING btree (a, lower(b)) WHERE (...)
97
+ const at = /\busing\s+\w+\s*\(/i.exec(idx.definition || '');
98
+ if (at) sets.push(identifiers(untilClose(String(idx.definition).slice(at.index + at[0].length).toLowerCase())));
99
+ }
100
+ return sets.filter((x) => x.length);
101
+ }
102
+
103
+ function tableOf(plan, table) {
104
+ return (plan.tables || []).find((x) => x.name === table) || { columns: [], constraints: [] };
105
+ }
106
+
107
+ /** FROM t [alias] WHERE ..., for the first table named after a point. The
108
+ * alias is never a keyword: reading "FROM t WHERE" as t aliased "where" lost
109
+ * the condition (assign_asset, record_receipt on round 16). */
110
+ function fromWhere(stmt) {
111
+ const m = new RegExp('\\bfrom\\s+(' + NAME + ')(?:\\s+(?:as\\s+)?(?!(?:where|for|limit|order|group|join|left|right|inner|cross|into|on|returning|union|then)\\b)[a-z_][a-z0-9_]*)?(?:\\s+where\\s+(.*))?').exec(stmt);
112
+ if (!m) return null;
113
+ const where = untilClose((m[2] || '').replace(/\b(for\s+(update|no key update|share|key share)|limit|order\s+by|group\s+by|returning)\b.*$/, ''));
114
+ return { table: bare(m[1]), where: where };
115
+ }
116
+
117
+ /** The columns a WHERE pins with "col = something". */
118
+ function pinnedBy(where) {
119
+ const pins = [];
120
+ const re = new RegExp('(' + NAME + ')\\s*=(?!=)', 'g');
121
+ for (let m; (m = re.exec(where));) pins.push(bare(m[1]));
122
+ return Array.from(new Set(pins)).sort();
123
+ }
124
+
125
+ function insertInto(stmts, from, table) {
126
+ for (let j = from; j < stmts.length; j++) {
127
+ const m = new RegExp('\\binsert\\s+into\\s+(' + NAME + ')').exec(stmts[j]);
128
+ // Only an ON CONFLICT that names its target: Postgres then needs a unique
129
+ // index to plan it, so the second caller is caught. A bare ON CONFLICT DO
130
+ // NOTHING on a table with no unique rule never conflicts at all.
131
+ if (m && bare(m[1]) === table) return { at: j, onConflict: /\bon\s+conflict\s*(\(|on\s+constraint\b)/.test(stmts[j]) };
132
+ }
133
+ return null;
134
+ }
135
+
136
+ /** SET a = <expr>, b = <expr>: each target with what it is set to. */
137
+ function setsOf(list) {
138
+ const out = [];
139
+ let depth = 0, cut = 0;
140
+ for (let k = 0; k <= list.length; k++) {
141
+ const ch = list[k];
142
+ if (ch === '(') depth++;
143
+ else if (ch === ')') depth--;
144
+ else if ((ch === ',' && depth === 0) || k === list.length) {
145
+ const m = new RegExp('^\\s*(' + NAME + ')\\s*=(.*)$').exec(list.slice(cut, k));
146
+ if (m) out.push({ column: bare(m[1]), expr: m[2] });
147
+ cut = k + 1;
148
+ }
149
+ }
150
+ return out;
151
+ }
152
+
153
+ /** SELECT <list> INTO <vars> FROM ...: split at the first " into " and the
154
+ * first " from " after it - by position, not a lazy pattern, so engine.sql
155
+ * (whose regular expressions are not lazy the same way) reads it alike. */
156
+ function selectInto(stmt) {
157
+ if (!stmt.startsWith('select ')) return null;
158
+ const rest = stmt.slice(7);
159
+ const a = rest.indexOf(' into ');
160
+ if (a < 0) return null;
161
+ let after = rest.slice(a + 6);
162
+ if (after.startsWith('strict ')) after = after.slice(7);
163
+ const b = after.indexOf(' from ');
164
+ if (b < 0) return null;
165
+ return { list: rest.slice(0, a), vars: after.slice(0, b) };
166
+ }
167
+
168
+ /** UPDATE t SET <list> [WHERE <cond>], split at the first " where ". */
169
+ function updateOf(stmt) {
170
+ const m = new RegExp('^update\\s+(?:only\\s+)?(' + NAME + ')\\s+(?:as\\s+[a-z_][a-z0-9_]*\\s+)?set\\s+(.*)$').exec(stmt);
171
+ if (!m) return null;
172
+ const w = m[2].indexOf(' where ');
173
+ return { table: bare(m[1]), list: w < 0 ? m[2] : m[2].slice(0, w), where: w < 0 ? '' : m[2].slice(w + 7) };
174
+ }
175
+
176
+ /** Every race shape one function's text shows, each marked safe or not. */
177
+ function shapesInFunction(fn, plan) {
178
+ const out = [];
179
+ const stmts = statementsOf(fn.def);
180
+ const locked = LOCKS.test(stmts.join('; '));
181
+ const tables = new Set((plan.tables || []).map((t) => t.name));
182
+ const lockedBy = (i) => stmts.slice(0, i + 1).some((x) => ROW_LOCK.test(x));
183
+ const add = (how, table, column, safe) => out.push({ how: how, table: table, column: column, safe: locked || safe });
184
+
185
+ stmts.forEach((s, i) => {
186
+ // number
187
+ const max = /\bmax\s*\(/.exec(s);
188
+ if (max && /\)\s*\+\s*1\b/.test(s.slice(max.index)) && /\binto\b/.test(s)) {
189
+ const src = fromWhere(s.slice(max.index));
190
+ if (!src || !tables.has(src.table)) return;
191
+ const cols = new Set(tableOf(plan, src.table).columns.map((c) => String(c.name).toLowerCase()));
192
+ const column = identifiers(untilClose(s.slice(max.index + max[0].length))).find((c) => cols.has(c));
193
+ if (!column) return;
194
+ const ins = insertInto(stmts, i + 1, src.table);
195
+ // Or handed back for the app to insert: reserve_subscription_code()
196
+ // returned "PREFIX-00042" built from it (round 11, HL-04), through any
197
+ // x := ... n ... on the way.
198
+ const into = /\binto\s+([a-z_][a-z0-9_$]*)\s+from\b/.exec(s);
199
+ const carried = into ? [into[1]] : [];
200
+ let handedBack = false;
201
+ for (const x of stmts.slice(i + 1)) {
202
+ const set = /^([a-z_][a-z0-9_$]*)\s*:=/.exec(x);
203
+ if (set && mentions(x, carried)) carried.push(set[1]);
204
+ else if (/^return\b/.test(x) && mentions(x, carried)) { handedBack = true; break; }
205
+ }
206
+ if (!ins && !handedBack) return;
207
+ add('number', src.table, column,
208
+ lockedBy(i) || (ins && ins.onConflict) || uniqueSetsOf(plan, src.table).some((set) => set.includes(column)));
209
+ return;
210
+ }
211
+
212
+ const read = selectInto(s);
213
+
214
+ // overdraw
215
+ if (read) {
216
+ const src = fromWhere(s);
217
+ if (src && tables.has(src.table)) {
218
+ const selected = identifiers(read.list);
219
+ const vars = identifiers(read.vars);
220
+ const tab = tableOf(plan, src.table);
221
+ const cols = new Set(tab.columns.map((c) => String(c.name).toLowerCase()));
222
+ const checks = tab.constraints.filter((c) => c.kind === 'c').map((c) => String(c.definition || '').toLowerCase());
223
+ for (let j = i + 1; j < stmts.length; j++) {
224
+ const up = updateOf(stmts[j]);
225
+ if (!up || up.table !== src.table) continue;
226
+ const refused = stmts.slice(i + 1, j).some((x) => /\braise\b/.test(x) && mentions(x, vars));
227
+ if (!refused) continue;
228
+ for (const { column, expr } of setsOf(up.list)) {
229
+ if (!selected.includes(column)) continue;
230
+ // A CHECK stops the second caller only when the UPDATE works on
231
+ // the column itself (a = a - x) and the CHECK is a floor under the
232
+ // subtraction, or ties the column to another one (reserved <=
233
+ // on_hand). "a = v - x", set from what was read, is the lost
234
+ // update itself and no CHECK sees it; reserve_stock's
235
+ // CHECK (reserved_quantity >= 0) does nothing for an addition.
236
+ const self = mentions(expr, [column]);
237
+ const held = self && checks.some((c) => mentions(c, [column]) &&
238
+ (/-/.test(expr) || identifiers(c).some((x) => x !== column && cols.has(x))));
239
+ add('overdraw', src.table, column, lockedBy(i) || mentions(up.where, [column]) || held);
240
+ }
241
+ }
242
+ }
243
+ }
244
+
245
+ // double
246
+ const exists = /\bexists\s*\(\s*select\b/.exec(s);
247
+ const lookup = exists ? fromWhere(s.slice(exists.index))
248
+ : read && !/\b(count|sum|min|avg|max)\s*\(/.test(read.list) ? fromWhere(s) : null;
249
+ if (!lookup || !tables.has(lookup.table)) return;
250
+ const pins = pinnedBy(lookup.where);
251
+ const ins = insertInto(stmts, i + 1, lookup.table);
252
+ if (!pins.length || !ins) return;
253
+ const vars = read && !exists ? identifiers(read.vars) : [];
254
+ const branches = exists ? /^(if|elsif)\b/.test(s)
255
+ : stmts.slice(i + 1, ins.at).some((x) => /^(if|elsif)\b/.test(x) && (/\bfound\b/.test(x) || mentions(x, vars)));
256
+ if (!branches) return;
257
+ add('double', lookup.table, pins.join(','),
258
+ lockedBy(i) || ins.onConflict || uniqueSetsOf(plan, lookup.table).some((set) => set.every((c) => pins.includes(c))));
259
+ });
260
+ return out;
261
+ }
262
+
263
+ /** Every shape in the app's functions: { fn, args, how, table, column, safe }. */
264
+ function raceShapesOf(plan) {
265
+ const out = [];
266
+ for (const fn of (plan && plan.functions) || []) {
267
+ if (fn.from_extension) continue;
268
+ if (fn.language && !/^(plpgsql|sql)$/.test(String(fn.language))) continue;
269
+ for (const shape of shapesInFunction(fn, plan)) out.push(Object.assign({ fn: fn.name, args: fn.args || '' }, shape));
270
+ }
271
+ return out;
272
+ }
273
+
274
+ const order = (a, b) => {
275
+ const x = a.fn + '\u0000' + a.how + '\u0000' + a.table + '\u0000' + a.column;
276
+ const y = b.fn + '\u0000' + b.how + '\u0000' + b.table + '\u0000' + b.column;
277
+ return x < y ? -1 : x > y ? 1 : 0;
278
+ };
279
+
280
+ /** The races to report: shapes nothing makes safe, one per function, how,
281
+ * table and column. */
282
+ function racesOf(plan) {
283
+ const seen = new Map();
284
+ for (const shape of raceShapesOf(plan)) {
285
+ const key = shape.fn + ':' + shape.how + ':' + shape.table + ':' + shape.column;
286
+ seen.set(key, seen.has(key) ? (seen.get(key) && shape.safe) : shape.safe);
287
+ }
288
+ const out = [];
289
+ for (const shape of raceShapesOf(plan)) {
290
+ const key = shape.fn + ':' + shape.how + ':' + shape.table + ':' + shape.column;
291
+ if (seen.get(key) !== false) continue;
292
+ seen.set(key, true);
293
+ out.push({ kind: 'race', how: shape.how, fn: shape.fn, args: shape.args, table: shape.table, column: shape.column, columns: [] });
294
+ }
295
+ return out.sort(order);
296
+ }
297
+
298
+ /** What counts as examined, for the re-check: every shape read, safe or not,
299
+ * keyed the way a finding is (recheck.js keyOf). */
300
+ function raceKeysOf(plan) {
301
+ return Array.from(new Set(raceShapesOf(plan).map((x) => 'race:' + x.table + ':' + x.column))).sort();
302
+ }
303
+
304
+ module.exports = { racesOf: racesOf, raceKeysOf: raceKeysOf, raceShapesOf: raceShapesOf, statementsOf: statementsOf };
package/recheck.js CHANGED
@@ -233,6 +233,7 @@ function whatOf(item) {
233
233
  if (item.kind === 'conflict') return 'an upsert Postgres refuses';
234
234
  if (item.kind === 'nullcheck') return 'a ' + (item.column || 'value') + ' that can be left empty';
235
235
  if (item.kind === 'noinsert') return 'no row can be added';
236
+ if (item.kind === 'race') return 'two calls at once in ' + (item.fn || 'a function');
236
237
  return item.kind;
237
238
  }
238
239
 
@@ -318,6 +319,9 @@ function describe(result) {
318
319
  : 'I asked Postgres to plan it again, and it accepted it.')
319
320
  : item.kind === 'noinsert'
320
321
  ? (elsewhere ? 'I looked here, and a rule for adding rows checks something.' : 'I looked again, and a rule for adding rows now checks something.')
322
+ : item.kind === 'race'
323
+ ? (elsewhere ? 'I read it here, and something stops a second call - a unique rule, a lock, or the check repeated in the same statement.'
324
+ : 'I read it again, and something now stops a second call - a unique rule, a lock, or the check repeated in the same statement.')
321
325
  : item.kind === 'nullcheck'
322
326
  ? (elsewhere ? 'I looked here, and it cannot be left empty.' : 'I looked again, and it can no longer be left empty.')
323
327
  : item.kind === 'recursive'