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 +172 -29
- package/bin/kryptheon-night.js +70 -16
- package/engine.sql +2883 -2628
- package/finding.js +21 -6
- package/intro.js +13 -5
- package/orphan.js +5 -37
- package/package.json +6 -2
- package/scan.js +141 -33
- package/schema.js +125 -9
- package/trouble.js +29 -3
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
|
@@ -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(
|
|
128
|
+
function keepTheNight(record) {
|
|
129
129
|
try {
|
|
130
|
-
scanner.saveNightly(scanner.nightlyFile(),
|
|
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
|
-
|
|
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('
|
|
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 ' +
|
|
248
|
-
|
|
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:
|
|
251
|
-
attacksRun:
|
|
252
|
-
notChecked:
|
|
253
|
-
findings:
|
|
276
|
+
stopped: record.stopped,
|
|
277
|
+
attacksRun: record.attacksRun,
|
|
278
|
+
notChecked: record.notChecked || [],
|
|
279
|
+
findings: record.findings || [],
|
|
254
280
|
});
|
|
255
|
-
return run.stopped ? 2 : (
|
|
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(() => {});
|