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/attack.js
CHANGED
|
@@ -228,6 +228,70 @@ function foreignKeys(table) {
|
|
|
228
228
|
return keys;
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
/** The single-column primary key of a table, or null if it has none. */
|
|
232
|
+
function primaryKeyOf(table) {
|
|
233
|
+
for (const constraint of table.constraints || []) {
|
|
234
|
+
if (constraint.kind !== 'p') continue;
|
|
235
|
+
const match = /PRIMARY KEY \(([^)]+)\)/i.exec(constraint.definition);
|
|
236
|
+
if (!match) continue;
|
|
237
|
+
const columns = match[1].split(',').map((name) => name.trim().split('"').join(''));
|
|
238
|
+
// A composite key is not something a single `<thing>_id` column points at.
|
|
239
|
+
return columns.length === 1 ? columns[0] : null;
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The table a column named `<thing>_id` is pointing at, if there is one.
|
|
246
|
+
*
|
|
247
|
+
* The name has to match a table that is really here, and the types have to
|
|
248
|
+
* agree. Both, because `stripe_id` matches nothing and `org_id integer` does
|
|
249
|
+
* not point at an `orgs.id` that is a uuid.
|
|
250
|
+
*/
|
|
251
|
+
function parentFor(column, tables) {
|
|
252
|
+
const stem = /^(.+)_id$/i.exec(column.name);
|
|
253
|
+
if (!stem) return null;
|
|
254
|
+
const wanted = stem[1].toLowerCase();
|
|
255
|
+
const parent = (tables || []).find((table) => {
|
|
256
|
+
const name = table.name.toLowerCase();
|
|
257
|
+
return name === wanted || name === wanted + 's' || name === wanted + 'es';
|
|
258
|
+
});
|
|
259
|
+
if (!parent) return null;
|
|
260
|
+
|
|
261
|
+
const key = primaryKeyOf(parent);
|
|
262
|
+
if (!key) return null;
|
|
263
|
+
const keyColumn = (parent.columns || []).find((c) => c.name === key);
|
|
264
|
+
if (!keyColumn || keyColumn.type !== column.type) return null;
|
|
265
|
+
|
|
266
|
+
return { table: parent, keyColumn: key, type: keyColumn.type };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The keys a table would have if it had been given them: a `<thing>_id`
|
|
271
|
+
* column naming a table that is here, with no foreign key on it.
|
|
272
|
+
*
|
|
273
|
+
* Found on a blind test. A production snapshot had no foreign keys at all, so
|
|
274
|
+
* workspace_members.workspace_id was seeded with an invented value that named
|
|
275
|
+
* no workspace. A view joining members to their workspaces then showed
|
|
276
|
+
* nothing, and "anyone can list every workspace and who is in it" - a view
|
|
277
|
+
* granted to logged-out visitors - read as an empty room.
|
|
278
|
+
*
|
|
279
|
+
* Only ever used to pick a value. Nothing is refused for lack of one: the
|
|
280
|
+
* database never promised the parent would be there.
|
|
281
|
+
*/
|
|
282
|
+
function impliedKeys(table, tables) {
|
|
283
|
+
const tied = new Set();
|
|
284
|
+
for (const key of foreignKeys(table)) key.columns.forEach((name) => tied.add(name));
|
|
285
|
+
const keys = [];
|
|
286
|
+
for (const column of table.columns || []) {
|
|
287
|
+
if (tied.has(column.name)) continue;
|
|
288
|
+
const parent = parentFor(column, tables);
|
|
289
|
+
if (!parent || parent.table.name === table.name) continue;
|
|
290
|
+
keys.push({ columns: [column.name], refTable: parent.table.name, refColumns: [parent.keyColumn] });
|
|
291
|
+
}
|
|
292
|
+
return keys;
|
|
293
|
+
}
|
|
294
|
+
|
|
231
295
|
/**
|
|
232
296
|
* Parents before children.
|
|
233
297
|
*
|
|
@@ -236,6 +300,9 @@ function foreignKeys(table) {
|
|
|
236
300
|
* pointed at, because every app has one of these. A cycle is left in whatever
|
|
237
301
|
* order it arrived: it cannot be satisfied anyway, and the table that fails is
|
|
238
302
|
* reported rather than dropped.
|
|
303
|
+
*
|
|
304
|
+
* A key the app never declared counts too. A member row seeded before the
|
|
305
|
+
* workspace it names has nothing real to point at.
|
|
239
306
|
*/
|
|
240
307
|
function dependencyOrder(tables) {
|
|
241
308
|
const byName = new Map(tables.map((t) => [t.name, t]));
|
|
@@ -246,7 +313,7 @@ function dependencyOrder(tables) {
|
|
|
246
313
|
const visit = (table) => {
|
|
247
314
|
if (done.has(table.name) || visiting.has(table.name)) return;
|
|
248
315
|
visiting.add(table.name);
|
|
249
|
-
for (const key of foreignKeys(table)) {
|
|
316
|
+
for (const key of foreignKeys(table).concat(impliedKeys(table, tables))) {
|
|
250
317
|
const parent = byName.get(key.refTable);
|
|
251
318
|
if (parent && parent.name !== table.name) visit(parent);
|
|
252
319
|
}
|
|
@@ -265,8 +332,15 @@ function dependencyOrder(tables) {
|
|
|
265
332
|
* The whole row, not one column of it. A composite key has to point at a pair
|
|
266
333
|
* that exists together: borrowing each column from a separate row would build
|
|
267
334
|
* a combination the parent never had.
|
|
335
|
+
*
|
|
336
|
+
* `mine` is the row seeded for the same person, when there is one. Without it
|
|
337
|
+
* both fake people were put in the first workspace - the same team, both as
|
|
338
|
+
* its owner - and every rule written "members of this workspace may read it"
|
|
339
|
+
* let each read the other's rows. Six tables of one blind test were reported
|
|
340
|
+
* as one customer reading another's data, when they were teammates.
|
|
268
341
|
*/
|
|
269
|
-
async function existingRow(client, schema, key) {
|
|
342
|
+
async function existingRow(client, schema, key, mine) {
|
|
343
|
+
if (mine) return key.refColumns.map((name) => mine[name]);
|
|
270
344
|
try {
|
|
271
345
|
const columns = key.refColumns.map((name, i) => quote(name) + ' AS v' + i).join(', ');
|
|
272
346
|
const { rows } = await client.query(
|
|
@@ -291,19 +365,28 @@ async function existingRow(client, schema, key) {
|
|
|
291
365
|
* they collide on that column and on nothing else. Without it a second unique
|
|
292
366
|
* column elsewhere in the table would refuse the insert, and the refusal would
|
|
293
367
|
* be read as the app defending itself.
|
|
368
|
+
*
|
|
369
|
+
* `seeding` is only passed by the seeder: `rows`, the rows already seeded for
|
|
370
|
+
* each person, table by table, so a row points at its own person's parent;
|
|
371
|
+
* and `implied`, the keys the app never declared, so a `workspace_id` with no
|
|
372
|
+
* foreign key still names a workspace that is there.
|
|
294
373
|
*/
|
|
295
|
-
async function rowFor(client, schema, table, person, distinct, overrides, attempt) {
|
|
374
|
+
async function rowFor(client, schema, table, person, distinct, overrides, attempt, seeding) {
|
|
296
375
|
const forced = overrides || {};
|
|
297
376
|
const owner = ownerColumn(table);
|
|
298
377
|
const columns = [];
|
|
299
378
|
const values = [];
|
|
379
|
+
const sown = (seeding && seeding.rows) || new Map();
|
|
380
|
+
const implied = (seeding && seeding.implied) || [];
|
|
300
381
|
|
|
301
382
|
// Every column that takes part in a foreign key, and the value it has to
|
|
302
383
|
// hold. Resolved one key at a time so that all of a composite key's columns
|
|
303
|
-
// come from the same parent row.
|
|
384
|
+
// come from the same parent row. Declared keys first, so a key the app wrote
|
|
385
|
+
// always wins over one guessed from a name.
|
|
304
386
|
const borrowed = new Map();
|
|
305
|
-
for (const key of foreignKeys(table)) {
|
|
306
|
-
const
|
|
387
|
+
for (const key of foreignKeys(table).concat(implied)) {
|
|
388
|
+
const theirs = sown.get(key.refTable);
|
|
389
|
+
const row = await existingRow(client, schema, key, theirs && theirs.get(person));
|
|
307
390
|
if (!row) continue;
|
|
308
391
|
key.columns.forEach((name, i) => {
|
|
309
392
|
if (!borrowed.has(name)) borrowed.set(name, row[i]);
|
|
@@ -345,6 +428,14 @@ async function rowFor(client, schema, table, person, distinct, overrides, attemp
|
|
|
345
428
|
// fact about how readColumns fills the shape, not about Postgres, and the
|
|
346
429
|
// day it changes this is the only thing standing in the way.
|
|
347
430
|
if (column.generated || column.identity) continue;
|
|
431
|
+
// A key nobody declared, pointing at a parent that is there. Filled even
|
|
432
|
+
// where a default or a null would do, because a row that names no real
|
|
433
|
+
// parent is invisible to every rule and view that joins through it.
|
|
434
|
+
if (borrowed.has(column.name)) {
|
|
435
|
+
columns.push(column.name);
|
|
436
|
+
values.push(borrowed.get(column.name));
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
348
439
|
// Anything with a default can supply its own value.
|
|
349
440
|
if (column.default_expr) continue;
|
|
350
441
|
if (!column.not_null) continue;
|
|
@@ -361,21 +452,36 @@ async function rowFor(client, schema, table, person, distinct, overrides, attemp
|
|
|
361
452
|
return { columns: columns, values: values };
|
|
362
453
|
}
|
|
363
454
|
|
|
364
|
-
/**
|
|
365
|
-
|
|
455
|
+
/**
|
|
456
|
+
* Puts a built row in. Separate so the same row can be raced against itself.
|
|
457
|
+
* `back` asks for the row as it landed, defaults and all.
|
|
458
|
+
*/
|
|
459
|
+
function insertRow(client, schema, table, row, back) {
|
|
366
460
|
const where = 'INSERT INTO ' + quote(schema) + '.' + quote(table);
|
|
461
|
+
const tail = back ? ' RETURNING *' : '';
|
|
367
462
|
// A table of nothing but an id and its defaults leaves no columns to name,
|
|
368
463
|
// and "INSERT INTO t () VALUES ()" is a syntax error. Postgres has a spelling
|
|
369
464
|
// for exactly this, and without it every settings and flags table in the
|
|
370
465
|
// world came back as one that could not be checked.
|
|
371
|
-
if (!row.columns.length) return client.query(where + ' DEFAULT VALUES');
|
|
466
|
+
if (!row.columns.length) return client.query(where + ' DEFAULT VALUES' + tail);
|
|
372
467
|
const placeholders = row.values.map((_, i) => '$' + (i + 1));
|
|
373
468
|
return client.query(
|
|
374
|
-
where + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' + placeholders.join(', ') + ')',
|
|
469
|
+
where + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' + placeholders.join(', ') + ')' + tail,
|
|
375
470
|
row.values,
|
|
376
471
|
);
|
|
377
472
|
}
|
|
378
473
|
|
|
474
|
+
/** Every table some other table points at, by a key it declared or one it implies. */
|
|
475
|
+
function parentTables(tables) {
|
|
476
|
+
const parents = new Set();
|
|
477
|
+
for (const table of tables) {
|
|
478
|
+
for (const key of foreignKeys(table).concat(impliedKeys(table, tables))) {
|
|
479
|
+
if (key.refTable !== table.name) parents.add(key.refTable);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return parents;
|
|
483
|
+
}
|
|
484
|
+
|
|
379
485
|
// How many differently-shaped values to try before giving up on a table. The
|
|
380
486
|
// rules that reject the first attempt - a domain insisting on an @, a CHECK on
|
|
381
487
|
// a length - cannot be read out of the catalogue, so the only honest way to
|
|
@@ -395,39 +501,57 @@ const SHAPES_TO_TRY = 5;
|
|
|
395
501
|
async function seed(client, schema, tables) {
|
|
396
502
|
const seeded = [];
|
|
397
503
|
const skipped = [];
|
|
504
|
+
// Each person's own rows, table by table, so that what they own points at
|
|
505
|
+
// their own workspace rather than the first one that went in.
|
|
506
|
+
const rows = new Map();
|
|
507
|
+
const parents = parentTables(tables);
|
|
398
508
|
for (const table of dependencyOrder(tables)) {
|
|
399
509
|
const owner = ownerColumn(table);
|
|
510
|
+
const seeding = { rows: rows, implied: impliedKeys(table, tables) };
|
|
400
511
|
|
|
401
512
|
// A table with nobody's name on it still gets a row. Without one, an open
|
|
402
513
|
// door cannot be told from an empty room: a logged-out stranger reads zero
|
|
403
514
|
// rows either way, and the tool reports the app as safe. Settings tables,
|
|
404
515
|
// waitlists and contact forms are exactly this shape, and exactly the ones
|
|
405
516
|
// that get left open.
|
|
406
|
-
|
|
517
|
+
//
|
|
518
|
+
// One that other tables point at - a workspace, a team, an organisation -
|
|
519
|
+
// gets one for each person, so the two of them are in different teams
|
|
520
|
+
// and a team rule is tested across teams rather than inside one. Some
|
|
521
|
+
// tables only ever hold one row, so a pair that will not go in falls back
|
|
522
|
+
// to the single row it always had.
|
|
523
|
+
const casts = owner ? [[USER_A, USER_B]]
|
|
524
|
+
: parents.has(table.name) ? [[USER_A, USER_B], [USER_A]] : [[USER_A]];
|
|
407
525
|
|
|
408
526
|
// Each attempt offers a differently-shaped set of values. A table whose
|
|
409
527
|
// rules reject all of them is reported, never quietly passed over.
|
|
410
528
|
let refused = null;
|
|
411
529
|
let landed = false;
|
|
412
530
|
let worked = 0;
|
|
413
|
-
for (
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
nth
|
|
418
|
-
|
|
419
|
-
|
|
531
|
+
for (const people of casts) {
|
|
532
|
+
for (let attempt = 0; attempt < SHAPES_TO_TRY && !landed; attempt++) {
|
|
533
|
+
const theirs = new Map();
|
|
534
|
+
try {
|
|
535
|
+
let nth = 0;
|
|
536
|
+
for (const person of people) {
|
|
537
|
+
nth += 1;
|
|
538
|
+
const row = await rowFor(client, schema, table, person, nth, null, attempt, seeding);
|
|
539
|
+
const result = await insertRow(client, schema, table.name, row, true);
|
|
540
|
+
theirs.set(person, result.rows[0]);
|
|
541
|
+
}
|
|
542
|
+
landed = true;
|
|
543
|
+
worked = attempt;
|
|
544
|
+
rows.set(table.name, theirs);
|
|
545
|
+
} catch (err) {
|
|
546
|
+
refused = err.message;
|
|
547
|
+
// A half-seeded table would make the next attempt collide with its own
|
|
548
|
+
// first row, so anything that did go in is taken back out.
|
|
549
|
+
await client
|
|
550
|
+
.query('DELETE FROM ' + quote(schema) + '.' + quote(table.name))
|
|
551
|
+
.catch(() => {});
|
|
420
552
|
}
|
|
421
|
-
landed = true;
|
|
422
|
-
worked = attempt;
|
|
423
|
-
} catch (err) {
|
|
424
|
-
refused = err.message;
|
|
425
|
-
// A half-seeded table would make the next attempt collide with its own
|
|
426
|
-
// first row, so anything that did go in is taken back out.
|
|
427
|
-
await client
|
|
428
|
-
.query('DELETE FROM ' + quote(schema) + '.' + quote(table.name))
|
|
429
|
-
.catch(() => {});
|
|
430
553
|
}
|
|
554
|
+
if (landed) break;
|
|
431
555
|
}
|
|
432
556
|
|
|
433
557
|
if (landed) {
|
|
@@ -513,6 +637,17 @@ function refusalMeans(message) {
|
|
|
513
637
|
* in its own right - and it names the table at fault, which is often not the
|
|
514
638
|
* one that was being read.
|
|
515
639
|
*/
|
|
640
|
+
/**
|
|
641
|
+
* The other shape the same loop takes. When the rule reaches its own table
|
|
642
|
+
* through a helper function rather than directly, Postgres does not see a
|
|
643
|
+
* policy recursing - it runs out of stack - and the message names no table.
|
|
644
|
+
* Found on a blind test: every signed-in read failed with "stack depth limit
|
|
645
|
+
* exceeded", and the report filed each one only as untested.
|
|
646
|
+
*/
|
|
647
|
+
function tooDeep(message) {
|
|
648
|
+
return /stack depth limit exceeded/i.test(String(message || ''));
|
|
649
|
+
}
|
|
650
|
+
|
|
516
651
|
function recursionIn(message) {
|
|
517
652
|
const found = /infinite recursion detected in policy for relation "([^"]+)"/i.exec(String(message || ''));
|
|
518
653
|
return found ? found[1] : null;
|
|
@@ -561,8 +696,11 @@ async function impersonate(client, schema, tables) {
|
|
|
561
696
|
// with nobody's name on it fails just the same, and it is the same
|
|
562
697
|
// broken page.
|
|
563
698
|
for (const [answer, who] of [[anon, 'anyone'], [asA, 'signed-in']]) {
|
|
564
|
-
|
|
565
|
-
|
|
699
|
+
if (Array.isArray(answer)) continue;
|
|
700
|
+
const relation = recursionIn(answer.blocked);
|
|
701
|
+
// A loop with no table named is recorded with none; the scan works out
|
|
702
|
+
// which table's rule it is from the rules and helpers it has read.
|
|
703
|
+
if (relation || tooDeep(answer.blocked)) looped.push({ relation: relation || null, table: table.name, who: who });
|
|
566
704
|
}
|
|
567
705
|
// Only a read of the table itself that came back with a verdict, for both
|
|
568
706
|
// callers, shows its rules no longer loop. A read that failed for some
|
|
@@ -617,9 +755,14 @@ module.exports = {
|
|
|
617
755
|
USER_D: USER_D,
|
|
618
756
|
refusalMeans: refusalMeans,
|
|
619
757
|
recursionIn: recursionIn,
|
|
758
|
+
tooDeep: tooDeep,
|
|
620
759
|
ownerColumn: ownerColumn,
|
|
621
760
|
fitTo: fitTo,
|
|
622
761
|
foreignKeys: foreignKeys,
|
|
762
|
+
primaryKeyOf: primaryKeyOf,
|
|
763
|
+
parentFor: parentFor,
|
|
764
|
+
impliedKeys: impliedKeys,
|
|
765
|
+
parentTables: parentTables,
|
|
623
766
|
dependencyOrder: dependencyOrder,
|
|
624
767
|
valueFor: valueFor,
|
|
625
768
|
allowedByCheck: allowedByCheck,
|
package/bin/kryptheon-night.js
CHANGED
|
@@ -614,7 +614,7 @@ async function main() {
|
|
|
614
614
|
try {
|
|
615
615
|
process.exitCode = await runVerb(asked.command, client, target);
|
|
616
616
|
} catch (err) {
|
|
617
|
-
block(trouble.explain(err, connection), fail);
|
|
617
|
+
block(trouble.explain(err, connection, { connected: true }), fail);
|
|
618
618
|
process.exitCode = 2;
|
|
619
619
|
} finally {
|
|
620
620
|
await client.end().catch(() => {});
|
|
@@ -668,7 +668,7 @@ async function main() {
|
|
|
668
668
|
// Anything that went wrong mid-scan. The copy has already been dropped by
|
|
669
669
|
// the `finally` inside the scan itself, so there is nothing to clean up
|
|
670
670
|
// here - only something to say.
|
|
671
|
-
block(trouble.explain(err, connection), fail);
|
|
671
|
+
block(trouble.explain(err, connection, { connected: true }), fail);
|
|
672
672
|
process.exitCode = 2;
|
|
673
673
|
} finally {
|
|
674
674
|
await client.end().catch(() => {});
|