kryptheon-night 0.1.19 → 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 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 row = await existingRow(client, schema, key);
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
- /** Puts a built row in. Separate so the same row can be raced against itself. */
365
- function insertRow(client, schema, table, row) {
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
- const people = owner ? [USER_A, USER_B] : [USER_A];
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 (let attempt = 0; attempt < SHAPES_TO_TRY && !landed; attempt++) {
414
- try {
415
- let nth = 0;
416
- for (const person of people) {
417
- nth += 1;
418
- const row = await rowFor(client, schema, table, person, nth, null, attempt);
419
- await insertRow(client, schema, table.name, row);
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
- const relation = !Array.isArray(answer) && recursionIn(answer.blocked);
565
- if (relation) looped.push({ relation: relation, table: table.name, who: who });
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,
@@ -32,8 +32,8 @@ const intro = require('../intro.js');
32
32
  const trouble = require('../trouble.js');
33
33
  const { howToConnect } = require('../connect.js');
34
34
  const installer = require('../installer.js');
35
- const finding = require('../finding.js');
36
35
  const outside = require('../outside.js');
36
+ const schema = require('../schema.js');
37
37
 
38
38
  const line = (text) => process.stdout.write(text + '\n');
39
39
  const fail = (text) => process.stderr.write(text + '\n');
@@ -88,7 +88,7 @@ function usage() {
88
88
  // names a schema: `public` is the schema almost everybody has, and a bare
89
89
  // word that is not one of these is still read as a schema so that
90
90
  // `kryptheon-night public` keeps working.
91
- const VERBS = ['install', 'uninstall', 'status', 'night'];
91
+ const VERBS = ['install', 'uninstall', 'status', 'night', 'words'];
92
92
 
93
93
  /** What was asked for, and what was left to the default. */
94
94
  function readArgs(argv) {
@@ -125,23 +125,43 @@ function readArgs(argv) {
125
125
  * their answer already, and a folder that cannot be written is not theirs to
126
126
  * debug here.
127
127
  */
128
- function keepTheNight(latest) {
128
+ function keepTheNight(record) {
129
129
  try {
130
- scanner.saveNightly(scanner.nightlyFile(), scanner.nightlyRecord(latest, new Date().toISOString()));
130
+ scanner.saveNightly(scanner.nightlyFile(), record);
131
131
  } catch (err) {
132
132
  /* the dashboard keeps its older copy, which carries its own date */
133
133
  }
134
134
  }
135
135
 
136
+ /**
137
+ * The night's answer, made whole: its findings told with what their tables
138
+ * need for a right fix, plus the functions anyone can call - both read from
139
+ * the shape now, read only. Without the shape it is still the night's answer,
140
+ * just less exact.
141
+ */
142
+ async function nightRecordFrom(client, latest) {
143
+ let plan = null;
144
+ const run = latest && latest.run;
145
+ if (run && !run.stopped) {
146
+ try {
147
+ plan = await schema.readSchema(client, run.source);
148
+ } catch (err) {
149
+ plan = null;
150
+ }
151
+ }
152
+ return scanner.nightlyRecord(latest, new Date().toISOString(), plan);
153
+ }
154
+
136
155
  /** Reads and keeps it, for the doors that do not already hold the answer. */
137
156
  async function readAndKeepTheNight(client) {
138
157
  try {
139
- keepTheNight(await installer.latestRun(client, {}));
158
+ keepTheNight(await nightRecordFrom(client, await installer.latestRun(client, {})));
140
159
  } catch (err) {
141
160
  /* as above */
142
161
  }
143
162
  }
144
163
 
164
+
145
165
  /**
146
166
  * The three doors that are not a scan.
147
167
  *
@@ -222,7 +242,8 @@ async function runVerb(command, client, target) {
222
242
 
223
243
  if (command === 'night') {
224
244
  const latest = await installer.latestRun(client, {});
225
- keepTheNight(latest);
245
+ const record = await nightRecordFrom(client, latest);
246
+ keepTheNight(record);
226
247
  const where = latest.where;
227
248
  if (!where) {
228
249
  line(' The nightly run is not installed in this database, so there is');
@@ -239,20 +260,25 @@ async function runVerb(command, client, target) {
239
260
  // product printed about a night that never happened.
240
261
  line(' It is installed, and it has not run yet.');
241
262
  line('');
242
- line(' The first run is at ' + (where.job ? where.job.schedule : 'whenever it is scheduled') + '.');
263
+ line(' It runs ' + scanner.scheduleTimes(where.job ? where.job.schedule : null) + '.');
243
264
  line('');
244
265
  return 0;
245
266
  }
246
267
  const run = latest.run;
247
- line(' From the night of ' + new Date(run.ran_at).toISOString().slice(0, 16).replace('T', ' ') +
248
- ', on "' + run.source + '":');
268
+ line(' From the night of ' + scanner.bothTimes(run.ran_at) + ', on "' + run.source + '".');
269
+ if (where.job) line(' It runs ' + scanner.scheduleTimes(where.job.schedule) + '.');
270
+ // The one part not from the night: the engine inside the database does
271
+ // not look at functions, so they are read now and said to be.
272
+ if (record.functionsReadAt) {
273
+ line(' Functions anyone can call were looked at just now, not overnight.');
274
+ }
249
275
  scanner.report({
250
- stopped: run.stopped,
251
- attacksRun: run.attacks_run,
252
- notChecked: run.not_checked || [],
253
- findings: finding.describeAll(run.findings || []),
276
+ stopped: record.stopped,
277
+ attacksRun: record.attacksRun,
278
+ notChecked: record.notChecked || [],
279
+ findings: record.findings || [],
254
280
  });
255
- return run.stopped ? 2 : (run.findings || []).length ? 1 : 0;
281
+ return run.stopped ? 2 : scanner.exitCodeFor(record);
256
282
  }
257
283
 
258
284
  throw new Error('there is no command called ' + command);
@@ -396,6 +422,23 @@ async function runOutside(appUrl) {
396
422
  return result.findings.length ? 1 : 0;
397
423
  }
398
424
 
425
+ /** Everything the dashboard needs to say what this command would say. */
426
+ function wordsFor(target, connection) {
427
+ const given = String(connection || '').trim();
428
+ const unusable = given ? trouble.readConnectionString(given) : null;
429
+ return {
430
+ help: intro.whereToFindIt(),
431
+ consent: intro.consentLines(target),
432
+ installConsent: intro.installConsentLines(installer.SCHEMA, target, installer.AT),
433
+ // pg_cron reads this in the database's own time zone, which is UTC on
434
+ // Supabase; the dashboard turns it into the person's local time.
435
+ nightlyAt: installer.AT,
436
+ given: Boolean(given),
437
+ unusable: unusable,
438
+ warning: given && !unusable ? trouble.poolerWarning(given) : null,
439
+ };
440
+ }
441
+
399
442
  /** Prints a block of plain lines with the indent the rest of the report uses. */
400
443
  function block(lines, write) {
401
444
  (write || line)('');
@@ -417,6 +460,17 @@ async function main() {
417
460
  process.exit(2);
418
461
  }
419
462
 
463
+ // The words this command says, as JSON, for the kryptheon dashboard to show
464
+ // on its own buttons: the help for finding the string, both consent
465
+ // screens, and - when KN_DATABASE_URL is given - what is wrong with it or
466
+ // worth warning about. Never connects, and never prints the string: the
467
+ // warnings name the host at most, and a string that cannot be used is
468
+ // described, not repeated.
469
+ if (asked.command === 'words') {
470
+ process.stdout.write(JSON.stringify(wordsFor(asked.schema || 'public', process.env.KN_DATABASE_URL || '')) + '\n');
471
+ return;
472
+ }
473
+
420
474
  // The outside attack. Needs no connection string, no copy and no yes: it
421
475
  // only asks the live app what it already answers any visitor, and asks for
422
476
  // counts, never rows. So nothing below - the string, the consent screen,
@@ -560,7 +614,7 @@ async function main() {
560
614
  try {
561
615
  process.exitCode = await runVerb(asked.command, client, target);
562
616
  } catch (err) {
563
- block(trouble.explain(err, connection), fail);
617
+ block(trouble.explain(err, connection, { connected: true }), fail);
564
618
  process.exitCode = 2;
565
619
  } finally {
566
620
  await client.end().catch(() => {});
@@ -614,7 +668,7 @@ async function main() {
614
668
  // Anything that went wrong mid-scan. The copy has already been dropped by
615
669
  // the `finally` inside the scan itself, so there is nothing to clean up
616
670
  // here - only something to say.
617
- block(trouble.explain(err, connection), fail);
671
+ block(trouble.explain(err, connection, { connected: true }), fail);
618
672
  process.exitCode = 2;
619
673
  } finally {
620
674
  await client.end().catch(() => {});