kryptheon-night 0.1.38 → 0.1.40

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 (9) hide show
  1. package/attack.js +1028 -1025
  2. package/bin/kryptheon-night.js +736 -725
  3. package/engine.sql +4657 -4366
  4. package/finding.js +1715 -1571
  5. package/intro.js +311 -303
  6. package/package.json +98 -98
  7. package/scan.js +1330 -1311
  8. package/schema.js +1588 -1531
  9. package/tamper.js +949 -726
@@ -1,725 +1,736 @@
1
- #!/usr/bin/env node
2
- //
3
- // The front door.
4
- //
5
- // npx kryptheon-night
6
- //
7
- // No arguments, no environment variable, no flags. It asks for what it needs,
8
- // says what it is about to do, waits to be told yes, and then runs.
9
- //
10
- // `scan.js` is the same program underneath and still takes its connection
11
- // string and schema on the command line - that is the door for scripts and
12
- // for the checks in this repo, and it has not changed. This file exists
13
- // because the person this product is for does not have a command line habit.
14
- // They have a Lovable app, a Supabase project somebody set up for them, and a
15
- // worry that anyone can read their customers table. Everything between them
16
- // and an answer is in this file.
17
- //
18
- // Exit codes are the same three as `scan.js`, because anything that automates
19
- // this reads those and not the words:
20
- //
21
- // 0 every attack that ran, lost
22
- // 1 something got through
23
- // 2 it could not run at all
24
-
25
- const path = require('path');
26
- const { Client } = require('pg');
27
-
28
- const scanner = require('../scan.js');
29
- const store = require('../store.js');
30
- const recheck = require('../recheck.js');
31
- const intro = require('../intro.js');
32
- const trouble = require('../trouble.js');
33
- const { howToConnect } = require('../connect.js');
34
- const installer = require('../installer.js');
35
- const outside = require('../outside.js');
36
- const schema = require('../schema.js');
37
-
38
- /**
39
- * The nightly summary, for a person on Pro or Studio: the project token and
40
- * the address it goes to, from the environment - never the command line,
41
- * which ends up in shell history. Null when no token is given, and then the
42
- * night sends nothing at all.
43
- */
44
- function reportToFromEnv() {
45
- const token = String(process.env.KRYPTHEON_PROJECT_TOKEN || '').trim();
46
- if (!token) return null;
47
- return {
48
- token: token,
49
- endpoint: String(process.env.KRYPTHEON_REPORT_URL || '').trim(),
50
- engine: engineVersion(),
51
- };
52
- }
53
-
54
- /** "kryptheon-night 0.1.30", for the dashboard - or just the name, if it cannot be read. */
55
- function engineVersion() {
56
- try {
57
- const manifest = JSON.parse(require('fs').readFileSync(require('path').join(__dirname, '..', 'package.json'), 'utf8'));
58
- return 'kryptheon-night ' + manifest.version;
59
- } catch (err) {
60
- return 'kryptheon-night';
61
- }
62
- }
63
-
64
- const line = (text) => process.stdout.write(text + '\n');
65
- const fail = (text) => process.stderr.write(text + '\n');
66
-
67
- function usage() {
68
- return [
69
- '',
70
- ' kryptheon-night - attacks a copy of your database and tells you what got in.',
71
- '',
72
- ' There is one command. It asks for what it needs:',
73
- '',
74
- ' npx kryptheon-night',
75
- '',
76
- ' No connection string? Give it your running app instead. It reads the app',
77
- ' the way a stranger does and tells you which tables anyone can read -',
78
- ' counts only, nothing written:',
79
- '',
80
- ' npx kryptheon-night https://your-app.example.com',
81
- '',
82
- ' Run it again after you fix something and it tells you what is actually',
83
- ' closed - you do not have to ask it to. It also offers to do the same',
84
- ' check every night from inside your database, and says so if it already',
85
- ' is. Nothing below is needed to use this.',
86
- '',
87
- ' For scripts, and for people who like knowing:',
88
- '',
89
- ' npx kryptheon-night night read back the last night it ran',
90
- ' npx kryptheon-night status is it installed, and is it running',
91
- ' npx kryptheon-night install set the nightly run up without being asked',
92
- ' npx kryptheon-night uninstall take it all out again',
93
- '',
94
- ' The nightly run needs pg_cron and pg_net. Supabase has both. It cannot',
95
- ' race two requests at once, so it is weaker than this command for the',
96
- ' "can this exist twice" question, and its report says which columns.',
97
- '',
98
- ' Options, none of them necessary:',
99
- '',
100
- ' --schema NAME the part of the database your app lives in.',
101
- ' Leave it out; it is "public" for almost everyone',
102
- ' --yes skip the "may I?" question. For scripts only',
103
- ' --recheck nothing any more - comparing against the last run',
104
- ' happens by itself. Kept so scripts do not break',
105
- ' --help this',
106
- '',
107
- ' The connection string can be put in KN_DATABASE_URL instead of being',
108
- ' typed in, which is how you would run this on a schedule.',
109
- '',
110
- ];
111
- }
112
-
113
- // The things this can be asked to do besides scan. Kept to verbs nobody
114
- // names a schema: `public` is the schema almost everybody has, and a bare
115
- // word that is not one of these is still read as a schema so that
116
- // `kryptheon-night public` keeps working.
117
- const VERBS = ['install', 'uninstall', 'status', 'night', 'words'];
118
-
119
- /** What was asked for, and what was left to the default. */
120
- function readArgs(argv) {
121
- const asked = {
122
- command: 'scan', schema: null, appUrl: null, recheck: false, yes: false, help: false, unknown: [],
123
- };
124
- for (let i = 0; i < argv.length; i++) {
125
- const arg = argv[i];
126
- // An address is the outside attack: the running app, read the way a
127
- // stranger's browser reads it. Never a schema name - no schema is called
128
- // https://anything.
129
- if (/^https?:\/\//i.test(arg) && !asked.appUrl) asked.appUrl = arg;
130
- else if (arg === '--recheck') asked.recheck = true;
131
- else if (arg === '--yes' || arg === '-y') asked.yes = true;
132
- else if (arg === '--help' || arg === '-h') asked.help = true;
133
- else if (arg === '--schema') asked.schema = argv[++i] || null;
134
- else if (arg.startsWith('--schema=')) asked.schema = arg.slice('--schema='.length);
135
- else if (arg.startsWith('-')) asked.unknown.push(arg);
136
- // The first bare word is a verb if it is one, and otherwise the schema -
137
- // so that somebody who has read the old instructions and types
138
- // `kryptheon-night public` is not told off. Anybody whose schema really
139
- // is called `status` can say so with --schema.
140
- else if (asked.command === 'scan' && !asked.schema && VERBS.includes(arg)) asked.command = arg;
141
- else if (!asked.schema) asked.schema = arg;
142
- else asked.unknown.push(arg);
143
- }
144
- return asked;
145
- }
146
-
147
- /**
148
- * Keeps what the nightly run last said where the kryptheon dashboard reads
149
- * it, so the answer waiting in the database is also on the page - not only
150
- * what was last run from this terminal. Never fails anything: the person has
151
- * their answer already, and a folder that cannot be written is not theirs to
152
- * debug here.
153
- */
154
- function keepTheNight(record) {
155
- try {
156
- scanner.saveNightly(scanner.nightlyFile(), record);
157
- } catch (err) {
158
- /* the dashboard keeps its older copy, which carries its own date */
159
- }
160
- }
161
-
162
- /**
163
- * The night's answer, made whole: its findings told with what their tables
164
- * need for a right fix, plus the functions anyone can call - both read from
165
- * the shape now, read only. Without the shape it is still the night's answer,
166
- * just less exact.
167
- */
168
- async function nightRecordFrom(client, latest) {
169
- let plan = null;
170
- const run = latest && latest.run;
171
- if (run && !run.stopped) {
172
- try {
173
- plan = await schema.readSchema(client, run.source);
174
- } catch (err) {
175
- plan = null;
176
- }
177
- }
178
- return scanner.nightlyRecord(latest, new Date().toISOString(), plan);
179
- }
180
-
181
- /** Reads and keeps it, for the doors that do not already hold the answer. */
182
- async function readAndKeepTheNight(client) {
183
- try {
184
- keepTheNight(await nightRecordFrom(client, await installer.latestRun(client, {})));
185
- } catch (err) {
186
- /* as above */
187
- }
188
- }
189
-
190
-
191
- /**
192
- * The three doors that are not a scan.
193
- *
194
- * Each returns the exit code, because anything automating this reads those
195
- * and not the words: 0 for nothing got through or nothing to say, 1 for
196
- * something did, 2 for could not.
197
- */
198
- async function runVerb(command, client, target) {
199
- if (command === 'status') {
200
- await readAndKeepTheNight(client);
201
- const where = await installer.status(client, {});
202
- if (!where) {
203
- line(' The nightly run is not installed in this database.');
204
- line('');
205
- line(' npx kryptheon-night install would set it up.');
206
- line('');
207
- return 0;
208
- }
209
- line(' Installed, and watching "' + where.source + '".');
210
- line('');
211
- if (where.job) {
212
- line(' job ' + where.job.jobname + ' ' + where.job.schedule +
213
- (where.job.active ? '' : ' (NOT ACTIVE)'));
214
- } else {
215
- // The one failure that looks like success from every other angle: the
216
- // schema is there, the functions are there, and nothing is running.
217
- line(' job MISSING - nothing is scheduled, so nothing runs');
218
- }
219
- line(' schema ' + where.schema);
220
- line(' since ' + new Date(where.installed_at).toISOString().slice(0, 16).replace('T', ' '));
221
- line('');
222
- const { rows } = await client.query(
223
- 'SELECT count(*)::int AS n, max(ran_at) AS last FROM ' +
224
- '"' + String(where.schema).split('"').join('""') + '".runs',
225
- ).catch(() => ({ rows: [{ n: 0, last: null }] }));
226
- line(' runs ' + rows[0].n +
227
- (rows[0].last ? ' last ' + new Date(rows[0].last).toISOString().slice(0, 16).replace('T', ' ') : ''));
228
- line('');
229
- return where.job ? 0 : 1;
230
- }
231
-
232
- if (command === 'install') {
233
- line(' Installing ...');
234
- const done = await installer.install(client, { source: target, reportTo: reportToFromEnv() });
235
- line('');
236
- line(' Installed. It will run at ' + done.at + ', watching "' + done.source + '".');
237
- if (done.reporting) line(' Each night it sends your Kryptheon dashboard a summary - never a row of data.');
238
- line('');
239
- if (done.made.extensions.length) {
240
- line(' I had to add: ' + done.made.extensions.join(', ') + '.');
241
- line(' Removing Kryptheon will take those back out again.');
242
- line('');
243
- }
244
- line(' Tomorrow morning: npx kryptheon-night night');
245
- line(' To remove it: npx kryptheon-night uninstall');
246
- line('');
247
- return 0;
248
- }
249
-
250
- if (command === 'uninstall') {
251
- const was = await installer.status(client, {});
252
- if (!was) {
253
- line(' There is nothing installed here to remove.');
254
- line('');
255
- return 0;
256
- }
257
- const removed = await installer.uninstall(client, {});
258
- await readAndKeepTheNight(client);
259
- line(' Removed.');
260
- line('');
261
- line(' the nightly job ' + (removed.job ? 'gone' : 'there was none'));
262
- line(' the schema ' + (removed.schema ? 'gone' : 'left, it was not mine to drop'));
263
- line(' extensions ' + (removed.extensions.length
264
- ? removed.extensions.join(', ') + ' - the ones I added'
265
- : 'none removed; they were here before me'));
266
- line('');
267
- return 0;
268
- }
269
-
270
- if (command === 'night') {
271
- const latest = await installer.latestRun(client, {});
272
- const record = await nightRecordFrom(client, latest);
273
- keepTheNight(record);
274
- const where = latest.where;
275
- if (!where) {
276
- line(' The nightly run is not installed in this database, so there is');
277
- line(' nothing to read back.');
278
- line('');
279
- line(' npx kryptheon-night would scan it now.');
280
- line(' npx kryptheon-night install would set up the nightly run.');
281
- line('');
282
- return 2;
283
- }
284
- if (!latest.run) {
285
- // Installed and never run is not the same as run and found nothing, and
286
- // saying "nothing got through" here would be the worst sentence in the
287
- // product printed about a night that never happened.
288
- line(' It is installed, and it has not run yet.');
289
- line('');
290
- line(' It runs ' + scanner.scheduleTimes(where.job ? where.job.schedule : null) + '.');
291
- line('');
292
- return 0;
293
- }
294
- const run = latest.run;
295
- line(' From the night of ' + scanner.bothTimes(run.ran_at) + ', on "' + run.source + '".');
296
- if (where.job) line(' It runs ' + scanner.scheduleTimes(where.job.schedule) + '.');
297
- // The one part not from the night: the engine inside the database does
298
- // not look at functions, so they are read now and said to be.
299
- if (record.functionsReadAt) {
300
- line(' Functions anyone can call were looked at just now, not overnight.');
301
- }
302
- scanner.report({
303
- stopped: record.stopped,
304
- attacksRun: record.attacksRun,
305
- notChecked: record.notChecked || [],
306
- findings: record.findings || [],
307
- });
308
- return run.stopped ? 2 : scanner.exitCodeFor(record);
309
- }
310
-
311
- throw new Error('there is no command called ' + command);
312
- }
313
-
314
- /**
315
- * After the report: say what the nightly run is doing, or offer it.
316
- *
317
- * Three states and each says something different. Installed and running gets
318
- * one line, because it is working and nobody needs a paragraph about it.
319
- * Installed with no job is the quiet failure - the schema is there, the
320
- * functions are there, and nothing runs - so it says so plainly. Not
321
- * installed gets the offer.
322
- *
323
- * Nothing here can make the scan fail. Whatever happens, the person has
324
- * already been given their answer, and losing it to an error about a nightly
325
- * job they never asked for would be the tool wasting the only thing it did.
326
- */
327
- async function offerTheNight(client, target, canAsk) {
328
- let where = null;
329
- try {
330
- where = await installer.status(client, {});
331
- } catch (err) {
332
- return;
333
- }
334
-
335
- if (where && where.job) {
336
- line(' I am also checking this every night, at ' + where.job.schedule + '.');
337
- line(' Read the last one with: npx kryptheon-night night');
338
- line('');
339
- return;
340
- }
341
-
342
- if (where && !where.job) {
343
- line(' The nightly check is installed here but nothing is scheduled, so it');
344
- line(' is not running. Setting it up again would fix that:');
345
- line('');
346
- line(' npx kryptheon-night install');
347
- line('');
348
- return;
349
- }
350
-
351
- if (!canAsk) {
352
- // Nobody to ask. Said once, quietly, rather than nagging a log file.
353
- line(' This can also run every night by itself: npx kryptheon-night install');
354
- line('');
355
- return;
356
- }
357
-
358
- const can = await installer.extensionState(client, 'pg_net').catch(() => ({ available: false }));
359
- const cron = await installer.extensionState(client, 'pg_cron').catch(() => ({ available: false }));
360
- if (!can.available || !cron.available) {
361
- // Not offered where it cannot happen. An offer that fails when accepted
362
- // is worse than no offer.
363
- return;
364
- }
365
-
366
- line(' One more thing.');
367
- line('');
368
- line(' What you just read is true about your app right now. It stops being');
369
- line(' true the next time anybody changes it - and that is the day nobody');
370
- line(' runs this. I can do exactly the same check every night, from inside');
371
- line(' your database, and have the answer waiting.');
372
- line('');
373
- const yes = await intro.askYesNo(' Set that up? (y/n) ');
374
- if (!yes) {
375
- line('');
376
- line(' Fine. It is here if you change your mind:');
377
- line(' npx kryptheon-night install');
378
- line('');
379
- return;
380
- }
381
-
382
- block(intro.installConsentLines(installer.SCHEMA, target, installer.AT, reportToFromEnv()));
383
- const sure = await intro.askYesNo(' Go ahead? (y/n) ');
384
- if (!sure) {
385
- line('');
386
- line(' Left alone. Nothing was installed.');
387
- line('');
388
- return;
389
- }
390
-
391
- try {
392
- const done = await installer.install(client, { source: target, reportTo: reportToFromEnv() });
393
- line('');
394
- line(' Done. It will run at ' + done.at + ', watching "' + done.source + '".');
395
- if (done.reporting) line(' Each night it sends your Kryptheon dashboard a summary - never a row of data.');
396
- if (done.made.extensions.length) {
397
- line(' I added ' + done.made.extensions.join(' and ') + '; uninstall takes them back out.');
398
- }
399
- line('');
400
- line(' Tomorrow: npx kryptheon-night night');
401
- line(' To remove: npx kryptheon-night uninstall');
402
- line('');
403
- } catch (err) {
404
- // The scan still stands. Say what failed and leave it at that.
405
- line('');
406
- line(' I could not set up the nightly run: ' + String(err.message).split('\n')[0]);
407
- line('');
408
- line(' The report above is still good. Nothing was left half-installed.');
409
- line('');
410
- await installer.uninstall(client, {}).catch(() => {});
411
- }
412
- }
413
-
414
- /**
415
- * The attack from outside, end to end: find the backend in the app, ask each
416
- * table as a stranger, say what came back and what could not be tested.
417
- *
418
- * Exit codes are the same three as the scan: 1 when a stranger read rows, 0
419
- * when nothing came back, 2 when it could not run at all.
420
- */
421
- async function runOutside(appUrl) {
422
- line('');
423
- line(' Kryptheon Night Shift - from outside');
424
- line(' I read your app the way a stranger\'s browser does, and ask your');
425
- line(' database what it lets a stranger see. Counts only, nothing written.');
426
- line('');
427
- line(' Reading ' + appUrl + ' ...');
428
-
429
- let found;
430
- try {
431
- found = await outside.discover(appUrl);
432
- } catch (err) {
433
- fail('');
434
- fail(' ' + (err.code ? err.message : 'I could not read ' + appUrl + ': ' + err.message));
435
- fail('');
436
- return 2;
437
- }
438
-
439
- let result;
440
- try {
441
- result = await outside.prowl(found);
442
- } catch (err) {
443
- fail('');
444
- fail(' I found your database but could not ask it anything: ' + err.message);
445
- fail('');
446
- return 2;
447
- }
448
-
449
- outside.reportLines(found, result).forEach(line);
450
- return result.findings.length ? 1 : 0;
451
- }
452
-
453
- /** Everything the dashboard needs to say what this command would say. */
454
- function wordsFor(target, connection) {
455
- const given = String(connection || '').trim();
456
- const unusable = given ? trouble.readConnectionString(given) : null;
457
- return {
458
- help: intro.whereToFindIt(),
459
- consent: intro.consentLines(target),
460
- installConsent: intro.installConsentLines(installer.SCHEMA, target, installer.AT, reportToFromEnv()),
461
- // pg_cron reads this in the database's own time zone, which is UTC on
462
- // Supabase; the dashboard turns it into the person's local time.
463
- nightlyAt: installer.AT,
464
- given: Boolean(given),
465
- unusable: unusable,
466
- warning: given && !unusable ? trouble.poolerWarning(given) : null,
467
- };
468
- }
469
-
470
- /** Prints a block of plain lines with the indent the rest of the report uses. */
471
- function block(lines, write) {
472
- (write || line)('');
473
- for (const text of lines) (write || line)(text ? ' ' + text : '');
474
- (write || line)('');
475
- }
476
-
477
- async function main() {
478
- const asked = readArgs(process.argv.slice(2));
479
-
480
- if (asked.help) {
481
- usage().forEach(line);
482
- return;
483
- }
484
- if (asked.unknown.length) {
485
- fail('');
486
- fail(' I do not know the option ' + asked.unknown[0] + '.');
487
- usage().forEach(fail);
488
- process.exit(2);
489
- }
490
-
491
- // The words this command says, as JSON, for the kryptheon dashboard to show
492
- // on its own buttons: the help for finding the string, both consent
493
- // screens, and - when KN_DATABASE_URL is given - what is wrong with it or
494
- // worth warning about. Never connects, and never prints the string: the
495
- // warnings name the host at most, and a string that cannot be used is
496
- // described, not repeated.
497
- if (asked.command === 'words') {
498
- process.stdout.write(JSON.stringify(wordsFor(asked.schema || 'public', process.env.KN_DATABASE_URL || '')) + '\n');
499
- return;
500
- }
501
-
502
- // The outside attack. Needs no connection string, no copy and no yes: it
503
- // only asks the live app what it already answers any visitor, and asks for
504
- // counts, never rows. So nothing below - the string, the consent screen,
505
- // the connection - applies to it.
506
- if (asked.appUrl) {
507
- process.exitCode = await runOutside(asked.appUrl);
508
- return;
509
- }
510
-
511
- line('');
512
- line(' Kryptheon Night Shift');
513
- line(' I attack a copy of your database and tell you what got in.');
514
-
515
- // Asking needs somewhere to ask. Piped into a script or run by a scheduler
516
- // there is no keyboard, and a prompt written to nobody looks exactly like a
517
- // program that has frozen.
518
- const canAsk = Boolean(process.stdin.isTTY && process.stdout.isTTY);
519
-
520
- let connection = process.env.KN_DATABASE_URL || '';
521
- if (!connection) {
522
- if (!canAsk) {
523
- fail('');
524
- fail(' I need a connection string and there is nobody here to ask.');
525
- fail('');
526
- fail(' Run me in a terminal window, or set KN_DATABASE_URL first.');
527
- fail('');
528
- process.exit(2);
529
- }
530
- connection = await intro.askForConnection(line);
531
- }
532
-
533
- // Everything that can be told from the string alone is told now, before a
534
- // single packet goes anywhere. These are the mistakes a person makes on
535
- // their first try, and Postgres describes every one of them badly.
536
- const unusable = trouble.readConnectionString(connection);
537
- if (unusable) {
538
- block(unusable, fail);
539
- process.exit(2);
540
- }
541
- connection = connection.trim().replace(/^["']|["']$/g, '');
542
-
543
- const warning = trouble.poolerWarning(connection);
544
- if (warning) block(warning);
545
-
546
- const target = asked.schema || 'public';
547
-
548
- // The password is not shown back, but the address is - it is the part a
549
- // person can check, and seeing their own project name appear is the first
550
- // sign that any of this is working.
551
- line('');
552
- line(' Database: ' + trouble.withoutSecret(connection));
553
-
554
- // Reading does not need permission; changing does. `status` and `night` only
555
- // read what is already written down, so asking would be ceremony.
556
- const needsAYes = asked.command === 'scan' || asked.command === 'install';
557
-
558
- if (needsAYes && !asked.yes) {
559
- if (!canAsk) {
560
- fail('');
561
- fail(' I will not connect to your database without being told yes, and');
562
- fail(' there is nobody here to ask.');
563
- fail('');
564
- fail(' Add --yes if you meant to run this unattended.');
565
- fail('');
566
- process.exit(2);
567
- }
568
- // Two different screens, because they promise different things. The scan's
569
- // ends "I do not leave anything behind", which is true of a scan and false
570
- // of an install - and this is the one screen the product cannot be loose on.
571
- block(asked.command === 'install'
572
- ? intro.installConsentLines(installer.SCHEMA, target, installer.AT, reportToFromEnv())
573
- : intro.consentLines(target));
574
- const yes = await intro.askYesNo(' Go ahead? (y/n) ');
575
- if (!yes) {
576
- line('');
577
- line(' Stopped. Nothing was connected to and nothing was changed.');
578
- line('');
579
- process.exit(0);
580
- }
581
- }
582
-
583
- // Compared against last time whenever there IS a last time, without being
584
- // asked.
585
- //
586
- // `--recheck` was a flag, and a flag is a thing somebody has to know about.
587
- // The person this is for fixed what the report told them to fix and typed
588
- // the same command again - and got a fresh report that said the same number
589
- // of problems, with no word about which of them they had just closed. The
590
- // tool knew. It had the file. It waited to be asked.
591
- //
592
- // The flag still works, because anything scripted may be passing it, and
593
- // now it changes nothing: with an earlier run here, this always compares.
594
- let file;
595
- try {
596
- file = scanner.lastRunFile(line);
597
- } catch (err) {
598
- // Nowhere to keep this run means nothing to compare the next one against,
599
- // and a re-check with nothing to compare against is not an answer.
600
- fail('');
601
- fail(' Kryptheon keeps its last run for this project in ' + store.dirFor(process.cwd()));
602
- fail(' and could not write there: ' + err.message);
603
- fail(' Set KRYPTHEON_HOME to a folder you can write to, and run this again.');
604
- fail('');
605
- process.exit(2);
606
- }
607
- const before = scanner.loadLastRun(file);
608
- if (asked.recheck && !before) {
609
- // Asked for explicitly and impossible, which is worth saying. Arriving
610
- // here by accident is not: without the flag, no earlier run simply means
611
- // this is the first one.
612
- fail('');
613
- fail(' There is no earlier run in this folder to compare against.');
614
- fail('');
615
- fail(' A re-check proves a fix worked by running the same attacks again and');
616
- fail(' comparing. With nothing to compare against it would report every');
617
- fail(' problem as new, which would look like an answer and would not be one.');
618
- fail('');
619
- fail(' Run it once first, and it will compare by itself from then on.');
620
- fail('');
621
- process.exit(2);
622
- }
623
-
624
- line('');
625
- line(' Connecting ...');
626
-
627
- let client;
628
- try {
629
- client = new Client(howToConnect(connection));
630
- await client.connect();
631
- } catch (err) {
632
- block(trouble.explain(err, connection), fail);
633
- process.exit(2);
634
- }
635
-
636
- line(' Connected.');
637
- line('');
638
-
639
- // The other three doors. Each one ends the command; only `scan` falls
640
- // through to the attack below.
641
- if (asked.command !== 'scan') {
642
- try {
643
- process.exitCode = await runVerb(asked.command, client, target);
644
- } catch (err) {
645
- block(trouble.explain(err, connection, { connected: true }), fail);
646
- process.exitCode = 2;
647
- } finally {
648
- await client.end().catch(() => {});
649
- }
650
- return;
651
- }
652
-
653
- try {
654
- const result = await scanner.scan(client, target, {
655
- // Two requests arriving at the same instant cannot be faked down one
656
- // connection, so the collision attack is handed a way to open its own.
657
- // This is the one attack the nightly installer will never be able to
658
- // run, and the report says so when it cannot.
659
- openSession: async () => {
660
- const extra = new Client(howToConnect(connection));
661
- await extra.connect();
662
- return extra;
663
- },
664
- });
665
-
666
- if (!before) {
667
- scanner.report(result);
668
- scanner.saveRun(file, result);
669
- process.exitCode = scanner.exitCodeFor(result);
670
- } else {
671
- const verdict = recheck.compare(before, result);
672
- recheck.describe(verdict).forEach(line);
673
- recheck.badgeLines(verdict, result.attacksRun || 0).forEach(line);
674
- // The report ends with what was not tested; with nothing left to report
675
- // that list still has to be printed, or the re-check goes quiet about it.
676
- if (result.findings.length) scanner.report(result);
677
- else scanner.notTestedLines(result).forEach(line);
678
- scanner.saveRun(file, result);
679
- // Two different databases: the verdict is a comparison, so the exit
680
- // code is this run's own answer, the same as a first run.
681
- process.exitCode = result.stopped ? 2 : verdict.elsewhere ? scanner.exitCodeFor(result) : verdict.allClear ? 0 : 1;
682
- }
683
-
684
- // And then the only other thing worth doing, offered rather than
685
- // documented.
686
- //
687
- // `install` was a verb, and a verb is a thing somebody has to find out
688
- // about. Nobody who clicked Deploy in Lovable is going to read a list of
689
- // subcommands - they will run this once, get their answer, and never
690
- // think about it again, which is exactly the app that is unprotected
691
- // three months later. The tool already knows whether the nightly run is
692
- // there. So it asks.
693
- // Whatever the nightly run last said is kept for the dashboard too, so
694
- // the page shows both answers and says which is which.
695
- await readAndKeepTheNight(client);
696
- if (!result.stopped) await offerTheNight(client, target, canAsk);
697
- } catch (err) {
698
- // Anything that went wrong mid-scan. The copy has already been dropped by
699
- // the `finally` inside the scan itself, so there is nothing to clean up
700
- // here - only something to say.
701
- block(trouble.explain(err, connection, { connected: true }), fail);
702
- process.exitCode = 2;
703
- } finally {
704
- await client.end().catch(() => {});
705
- }
706
- }
707
-
708
- // Ctrl-C during the attack leaves a copy behind, because the process dies
709
- // before the `finally` that drops it runs. The next run sweeps it up, but the
710
- // person deserves to know it is there rather than finding it themselves.
711
- process.on('SIGINT', () => {
712
- line('');
713
- line('');
714
- line(' Stopped.');
715
- line('');
716
- line(' If I had already made the temporary copy, it is still in your');
717
- line(' database. Run me again and I will delete it before I start.');
718
- line('');
719
- process.exit(130);
720
- });
721
-
722
- main().catch((err) => {
723
- block(trouble.explain(err, process.env.KN_DATABASE_URL || ''), fail);
724
- process.exit(2);
725
- });
1
+ #!/usr/bin/env node
2
+ //
3
+ // The front door.
4
+ //
5
+ // npx kryptheon-night
6
+ //
7
+ // No arguments, no environment variable, no flags. It asks for what it needs,
8
+ // says what it is about to do, waits to be told yes, and then runs.
9
+ //
10
+ // `scan.js` is the same program underneath and still takes its connection
11
+ // string and schema on the command line - that is the door for scripts and
12
+ // for the checks in this repo, and it has not changed. This file exists
13
+ // because the person this product is for does not have a command line habit.
14
+ // They have a Lovable app, a Supabase project somebody set up for them, and a
15
+ // worry that anyone can read their customers table. Everything between them
16
+ // and an answer is in this file.
17
+ //
18
+ // Exit codes are the same three as `scan.js`, because anything that automates
19
+ // this reads those and not the words:
20
+ //
21
+ // 0 every attack that ran, lost
22
+ // 1 something got through
23
+ // 2 it could not run at all
24
+
25
+ const path = require('path');
26
+ const { Client } = require('pg');
27
+
28
+ const scanner = require('../scan.js');
29
+ const store = require('../store.js');
30
+ const recheck = require('../recheck.js');
31
+ const intro = require('../intro.js');
32
+ const trouble = require('../trouble.js');
33
+ const { howToConnect } = require('../connect.js');
34
+ const installer = require('../installer.js');
35
+ const outside = require('../outside.js');
36
+ const schema = require('../schema.js');
37
+
38
+ /**
39
+ * The nightly summary, for a person on Pro or Studio: the project token and
40
+ * the address it goes to, from the environment - never the command line,
41
+ * which ends up in shell history. Null when no token is given, and then the
42
+ * night sends nothing at all.
43
+ */
44
+ function reportToFromEnv() {
45
+ const token = String(process.env.KRYPTHEON_PROJECT_TOKEN || '').trim();
46
+ if (!token) return null;
47
+ return {
48
+ token: token,
49
+ endpoint: String(process.env.KRYPTHEON_REPORT_URL || '').trim(),
50
+ engine: engineVersion(),
51
+ };
52
+ }
53
+
54
+ /** "kryptheon-night 0.1.30", for the dashboard - or just the name, if it cannot be read. */
55
+ function engineVersion() {
56
+ try {
57
+ const manifest = JSON.parse(require('fs').readFileSync(require('path').join(__dirname, '..', 'package.json'), 'utf8'));
58
+ return 'kryptheon-night ' + manifest.version;
59
+ } catch (err) {
60
+ return 'kryptheon-night';
61
+ }
62
+ }
63
+
64
+ const line = (text) => process.stdout.write(text + '\n');
65
+ const fail = (text) => process.stderr.write(text + '\n');
66
+
67
+ function usage() {
68
+ return [
69
+ '',
70
+ ' kryptheon-night - attacks a copy of your database and tells you what got in.',
71
+ '',
72
+ ' There is one command. It asks for what it needs:',
73
+ '',
74
+ ' npx kryptheon-night',
75
+ '',
76
+ ' No connection string? Give it your running app instead. It reads the app',
77
+ ' the way a stranger does and tells you which tables anyone can read -',
78
+ ' counts only, nothing written:',
79
+ '',
80
+ ' npx kryptheon-night https://your-app.example.com',
81
+ '',
82
+ ' Run it again after you fix something and it tells you what is actually',
83
+ ' closed - you do not have to ask it to. It also offers to do the same',
84
+ ' check every night from inside your database, and says so if it already',
85
+ ' is. Nothing below is needed to use this.',
86
+ '',
87
+ ' For scripts, and for people who like knowing:',
88
+ '',
89
+ ' npx kryptheon-night night read back the last night it ran',
90
+ ' npx kryptheon-night status is it installed, and is it running',
91
+ ' npx kryptheon-night install set the nightly run up without being asked',
92
+ ' npx kryptheon-night uninstall take it all out again',
93
+ '',
94
+ ' The nightly run needs pg_cron and pg_net. Supabase has both. It cannot',
95
+ ' race two requests at once, so it is weaker than this command for the',
96
+ ' "can this exist twice" question, and its report says which columns.',
97
+ '',
98
+ ' Options, none of them necessary:',
99
+ '',
100
+ ' --schema NAME the part of the database your app lives in.',
101
+ ' Leave it out; it is "public" for almost everyone',
102
+ ' --yes skip the "may I?" question. For scripts only',
103
+ ' --recheck nothing any more - comparing against the last run',
104
+ ' happens by itself. Kept so scripts do not break',
105
+ ' --help this',
106
+ '',
107
+ ' The connection string can be put in KN_DATABASE_URL instead of being',
108
+ ' typed in, which is how you would run this on a schedule.',
109
+ '',
110
+ ];
111
+ }
112
+
113
+ // The things this can be asked to do besides scan. Kept to verbs nobody
114
+ // names a schema: `public` is the schema almost everybody has, and a bare
115
+ // word that is not one of these is still read as a schema so that
116
+ // `kryptheon-night public` keeps working.
117
+ const VERBS = ['install', 'uninstall', 'status', 'night', 'words'];
118
+
119
+ /** What was asked for, and what was left to the default. */
120
+ function readArgs(argv) {
121
+ const asked = {
122
+ command: 'scan', schema: null, appUrl: null, recheck: false, yes: false, help: false, unknown: [],
123
+ };
124
+ for (let i = 0; i < argv.length; i++) {
125
+ const arg = argv[i];
126
+ // An address is the outside attack: the running app, read the way a
127
+ // stranger's browser reads it. Never a schema name - no schema is called
128
+ // https://anything.
129
+ if (/^https?:\/\//i.test(arg) && !asked.appUrl) asked.appUrl = arg;
130
+ else if (arg === '--recheck') asked.recheck = true;
131
+ else if (arg === '--yes' || arg === '-y') asked.yes = true;
132
+ else if (arg === '--help' || arg === '-h') asked.help = true;
133
+ else if (arg === '--schema') asked.schema = argv[++i] || null;
134
+ else if (arg.startsWith('--schema=')) asked.schema = arg.slice('--schema='.length);
135
+ else if (arg.startsWith('-')) asked.unknown.push(arg);
136
+ // The first bare word is a verb if it is one, and otherwise the schema -
137
+ // so that somebody who has read the old instructions and types
138
+ // `kryptheon-night public` is not told off. Anybody whose schema really
139
+ // is called `status` can say so with --schema.
140
+ else if (asked.command === 'scan' && !asked.schema && VERBS.includes(arg)) asked.command = arg;
141
+ else if (!asked.schema) asked.schema = arg;
142
+ else asked.unknown.push(arg);
143
+ }
144
+ return asked;
145
+ }
146
+
147
+ /**
148
+ * Keeps what the nightly run last said where the kryptheon dashboard reads
149
+ * it, so the answer waiting in the database is also on the page - not only
150
+ * what was last run from this terminal. Never fails anything: the person has
151
+ * their answer already, and a folder that cannot be written is not theirs to
152
+ * debug here.
153
+ */
154
+ function keepTheNight(record) {
155
+ try {
156
+ scanner.saveNightly(scanner.nightlyFile(), record);
157
+ } catch (err) {
158
+ /* the dashboard keeps its older copy, which carries its own date */
159
+ }
160
+ }
161
+
162
+ /**
163
+ * The night's answer, made whole: its findings told with what their tables
164
+ * need for a right fix, plus the functions anyone can call - both read from
165
+ * the shape now, read only. Without the shape it is still the night's answer,
166
+ * just less exact.
167
+ */
168
+ async function nightRecordFrom(client, latest) {
169
+ let plan = null;
170
+ const run = latest && latest.run;
171
+ if (run && !run.stopped) {
172
+ try {
173
+ plan = await schema.readSchema(client, run.source);
174
+ } catch (err) {
175
+ plan = null;
176
+ }
177
+ }
178
+ return scanner.nightlyRecord(latest, new Date().toISOString(), plan);
179
+ }
180
+
181
+ /** Reads and keeps it, for the doors that do not already hold the answer. */
182
+ async function readAndKeepTheNight(client) {
183
+ try {
184
+ keepTheNight(await nightRecordFrom(client, await installer.latestRun(client, {})));
185
+ } catch (err) {
186
+ /* as above */
187
+ }
188
+ }
189
+
190
+
191
+ /**
192
+ * The three doors that are not a scan.
193
+ *
194
+ * Each returns the exit code, because anything automating this reads those
195
+ * and not the words: 0 for nothing got through or nothing to say, 1 for
196
+ * something did, 2 for could not.
197
+ */
198
+ async function runVerb(command, client, target) {
199
+ if (command === 'status') {
200
+ await readAndKeepTheNight(client);
201
+ const where = await installer.status(client, {});
202
+ if (!where) {
203
+ line(' The nightly run is not installed in this database.');
204
+ line('');
205
+ line(' npx kryptheon-night install would set it up.');
206
+ line('');
207
+ return 0;
208
+ }
209
+ line(' Installed, and watching "' + where.source + '".');
210
+ line('');
211
+ if (where.job) {
212
+ line(' job ' + where.job.jobname + ' ' + where.job.schedule +
213
+ (where.job.active ? '' : ' (NOT ACTIVE)'));
214
+ } else {
215
+ // The one failure that looks like success from every other angle: the
216
+ // schema is there, the functions are there, and nothing is running.
217
+ line(' job MISSING - nothing is scheduled, so nothing runs');
218
+ }
219
+ line(' schema ' + where.schema);
220
+ line(' since ' + new Date(where.installed_at).toISOString().slice(0, 16).replace('T', ' '));
221
+ line('');
222
+ const { rows } = await client.query(
223
+ 'SELECT count(*)::int AS n, max(ran_at) AS last FROM ' +
224
+ '"' + String(where.schema).split('"').join('""') + '".runs',
225
+ ).catch(() => ({ rows: [{ n: 0, last: null }] }));
226
+ line(' runs ' + rows[0].n +
227
+ (rows[0].last ? ' last ' + new Date(rows[0].last).toISOString().slice(0, 16).replace('T', ' ') : ''));
228
+ line('');
229
+ return where.job ? 0 : 1;
230
+ }
231
+
232
+ if (command === 'install') {
233
+ line(' Installing ...');
234
+ const done = await installer.install(client, { source: target, reportTo: reportToFromEnv() });
235
+ line('');
236
+ line(' Installed. It will run at ' + done.at + ', watching "' + done.source + '".');
237
+ if (done.reporting) line(' Each night it sends your Kryptheon dashboard a summary - never a row of data.');
238
+ line('');
239
+ if (done.made.extensions.length) {
240
+ line(' I had to add: ' + done.made.extensions.join(', ') + '.');
241
+ line(' Removing Kryptheon will take those back out again.');
242
+ line('');
243
+ }
244
+ line(' Tomorrow morning: npx kryptheon-night night');
245
+ line(' To remove it: npx kryptheon-night uninstall');
246
+ line('');
247
+ return 0;
248
+ }
249
+
250
+ if (command === 'uninstall') {
251
+ const was = await installer.status(client, {});
252
+ if (!was) {
253
+ line(' There is nothing installed here to remove.');
254
+ line('');
255
+ return 0;
256
+ }
257
+ const removed = await installer.uninstall(client, {});
258
+ await readAndKeepTheNight(client);
259
+ line(' Removed.');
260
+ line('');
261
+ line(' the nightly job ' + (removed.job ? 'gone' : 'there was none'));
262
+ line(' the schema ' + (removed.schema ? 'gone' : 'left, it was not mine to drop'));
263
+ line(' extensions ' + (removed.extensions.length
264
+ ? removed.extensions.join(', ') + ' - the ones I added'
265
+ : 'none removed; they were here before me'));
266
+ line('');
267
+ return 0;
268
+ }
269
+
270
+ if (command === 'night') {
271
+ const latest = await installer.latestRun(client, {});
272
+ const record = await nightRecordFrom(client, latest);
273
+ keepTheNight(record);
274
+ const where = latest.where;
275
+ if (!where) {
276
+ line(' The nightly run is not installed in this database, so there is');
277
+ line(' nothing to read back.');
278
+ line('');
279
+ line(' npx kryptheon-night would scan it now.');
280
+ line(' npx kryptheon-night install would set up the nightly run.');
281
+ line('');
282
+ return 2;
283
+ }
284
+ if (!latest.run) {
285
+ // Installed and never run is not the same as run and found nothing, and
286
+ // saying "nothing got through" here would be the worst sentence in the
287
+ // product printed about a night that never happened.
288
+ line(' It is installed, and it has not run yet.');
289
+ line('');
290
+ line(' It runs ' + scanner.scheduleTimes(where.job ? where.job.schedule : null) + '.');
291
+ line('');
292
+ return 0;
293
+ }
294
+ const run = latest.run;
295
+ line(' From the night of ' + scanner.bothTimes(run.ran_at) + ', on "' + run.source + '".');
296
+ if (where.job) line(' It runs ' + scanner.scheduleTimes(where.job.schedule) + '.');
297
+ // The one part not from the night: the engine inside the database does
298
+ // not look at functions, so they are read now and said to be.
299
+ if (record.functionsReadAt) {
300
+ line(' Functions anyone can call were looked at just now, not overnight.');
301
+ }
302
+ scanner.report({
303
+ stopped: record.stopped,
304
+ attacksRun: record.attacksRun,
305
+ notChecked: record.notChecked || [],
306
+ findings: record.findings || [],
307
+ });
308
+ return run.stopped ? 2 : scanner.exitCodeFor(record);
309
+ }
310
+
311
+ throw new Error('there is no command called ' + command);
312
+ }
313
+
314
+ /**
315
+ * After the report: say what the nightly run is doing, or offer it.
316
+ *
317
+ * Three states and each says something different. Installed and running gets
318
+ * one line, because it is working and nobody needs a paragraph about it.
319
+ * Installed with no job is the quiet failure - the schema is there, the
320
+ * functions are there, and nothing runs - so it says so plainly. Not
321
+ * installed gets the offer.
322
+ *
323
+ * Nothing here can make the scan fail. Whatever happens, the person has
324
+ * already been given their answer, and losing it to an error about a nightly
325
+ * job they never asked for would be the tool wasting the only thing it did.
326
+ */
327
+ async function offerTheNight(client, target, canAsk) {
328
+ let where = null;
329
+ try {
330
+ where = await installer.status(client, {});
331
+ } catch (err) {
332
+ return;
333
+ }
334
+
335
+ if (where && where.job) {
336
+ line(' I am also checking this every night, at ' + where.job.schedule + '.');
337
+ line(' Read the last one with: npx kryptheon-night night');
338
+ line('');
339
+ return;
340
+ }
341
+
342
+ if (where && !where.job) {
343
+ line(' The nightly check is installed here but nothing is scheduled, so it');
344
+ line(' is not running. Setting it up again would fix that:');
345
+ line('');
346
+ line(' npx kryptheon-night install');
347
+ line('');
348
+ return;
349
+ }
350
+
351
+ if (!canAsk) {
352
+ // Nobody to ask. Said once, quietly, rather than nagging a log file.
353
+ line(' This can also run every night by itself: npx kryptheon-night install');
354
+ line('');
355
+ return;
356
+ }
357
+
358
+ const can = await installer.extensionState(client, 'pg_net').catch(() => ({ available: false }));
359
+ const cron = await installer.extensionState(client, 'pg_cron').catch(() => ({ available: false }));
360
+ if (!can.available || !cron.available) {
361
+ // Not offered where it cannot happen. An offer that fails when accepted
362
+ // is worse than no offer.
363
+ return;
364
+ }
365
+
366
+ line(' One more thing.');
367
+ line('');
368
+ line(' What you just read is true about your app right now. It stops being');
369
+ line(' true the next time anybody changes it - and that is the day nobody');
370
+ line(' runs this. I can do exactly the same check every night, from inside');
371
+ line(' your database, and have the answer waiting.');
372
+ line('');
373
+ const yes = await intro.askYesNo(' Set that up? (y/n) ');
374
+ if (!yes) {
375
+ line('');
376
+ line(' Fine. It is here if you change your mind:');
377
+ line(' npx kryptheon-night install');
378
+ line('');
379
+ return;
380
+ }
381
+
382
+ block(intro.installConsentLines(installer.SCHEMA, target, installer.AT, reportToFromEnv()));
383
+ const sure = await intro.askYesNo(' Go ahead? (y/n) ');
384
+ if (!sure) {
385
+ line('');
386
+ line(' Left alone. Nothing was installed.');
387
+ line('');
388
+ return;
389
+ }
390
+
391
+ try {
392
+ const done = await installer.install(client, { source: target, reportTo: reportToFromEnv() });
393
+ line('');
394
+ line(' Done. It will run at ' + done.at + ', watching "' + done.source + '".');
395
+ if (done.reporting) line(' Each night it sends your Kryptheon dashboard a summary - never a row of data.');
396
+ if (done.made.extensions.length) {
397
+ line(' I added ' + done.made.extensions.join(' and ') + '; uninstall takes them back out.');
398
+ }
399
+ line('');
400
+ line(' Tomorrow: npx kryptheon-night night');
401
+ line(' To remove: npx kryptheon-night uninstall');
402
+ line('');
403
+ } catch (err) {
404
+ // The scan still stands. Say what failed and leave it at that.
405
+ line('');
406
+ line(' I could not set up the nightly run: ' + String(err.message).split('\n')[0]);
407
+ line('');
408
+ line(' The report above is still good. Nothing was left half-installed.');
409
+ line('');
410
+ await installer.uninstall(client, {}).catch(() => {});
411
+ }
412
+ }
413
+
414
+ /**
415
+ * The attack from outside, end to end: find the backend in the app, ask each
416
+ * table as a stranger, say what came back and what could not be tested.
417
+ *
418
+ * Exit codes are the same three as the scan: 1 when a stranger read rows, 0
419
+ * when nothing came back, 2 when it could not run at all.
420
+ */
421
+ async function runOutside(appUrl) {
422
+ line('');
423
+ line(' Kryptheon Night Shift - from outside');
424
+ line(' I read your app the way a stranger\'s browser does, and ask your');
425
+ line(' database what it lets a stranger see. Counts only, nothing written.');
426
+ line('');
427
+ line(' Reading ' + appUrl + ' ...');
428
+
429
+ let found;
430
+ try {
431
+ found = await outside.discover(appUrl);
432
+ } catch (err) {
433
+ fail('');
434
+ fail(' ' + (err.code ? err.message : 'I could not read ' + appUrl + ': ' + err.message));
435
+ fail('');
436
+ return 2;
437
+ }
438
+
439
+ let result;
440
+ try {
441
+ result = await outside.prowl(found);
442
+ } catch (err) {
443
+ fail('');
444
+ fail(' I found your database but could not ask it anything: ' + err.message);
445
+ fail('');
446
+ return 2;
447
+ }
448
+
449
+ outside.reportLines(found, result).forEach(line);
450
+ return result.findings.length ? 1 : 0;
451
+ }
452
+
453
+ /** Everything the dashboard needs to say what this command would say. */
454
+ function wordsFor(target, connection) {
455
+ const given = String(connection || '').trim();
456
+ const unusable = given ? trouble.readConnectionString(given) : null;
457
+ return {
458
+ help: intro.whereToFindIt(),
459
+ consent: intro.consentLines(target || null),
460
+ installConsent: intro.installConsentLines(installer.SCHEMA, target || 'public', installer.AT, reportToFromEnv()),
461
+ // pg_cron reads this in the database's own time zone, which is UTC on
462
+ // Supabase; the dashboard turns it into the person's local time.
463
+ nightlyAt: installer.AT,
464
+ given: Boolean(given),
465
+ unusable: unusable,
466
+ warning: given && !unusable ? trouble.poolerWarning(given) : null,
467
+ };
468
+ }
469
+
470
+ /** Prints a block of plain lines with the indent the rest of the report uses. */
471
+ function block(lines, write) {
472
+ (write || line)('');
473
+ for (const text of lines) (write || line)(text ? ' ' + text : '');
474
+ (write || line)('');
475
+ }
476
+
477
+ async function main() {
478
+ const asked = readArgs(process.argv.slice(2));
479
+
480
+ if (asked.help) {
481
+ usage().forEach(line);
482
+ return;
483
+ }
484
+ if (asked.unknown.length) {
485
+ fail('');
486
+ fail(' I do not know the option ' + asked.unknown[0] + '.');
487
+ usage().forEach(fail);
488
+ process.exit(2);
489
+ }
490
+
491
+ // The words this command says, as JSON, for the kryptheon dashboard to show
492
+ // on its own buttons: the help for finding the string, both consent
493
+ // screens, and - when KN_DATABASE_URL is given - what is wrong with it or
494
+ // worth warning about. Never connects, and never prints the string: the
495
+ // warnings name the host at most, and a string that cannot be used is
496
+ // described, not repeated.
497
+ if (asked.command === 'words') {
498
+ process.stdout.write(JSON.stringify(wordsFor(asked.schema || null, process.env.KN_DATABASE_URL || '')) + '\n');
499
+ return;
500
+ }
501
+
502
+ // The outside attack. Needs no connection string, no copy and no yes: it
503
+ // only asks the live app what it already answers any visitor, and asks for
504
+ // counts, never rows. So nothing below - the string, the consent screen,
505
+ // the connection - applies to it.
506
+ if (asked.appUrl) {
507
+ process.exitCode = await runOutside(asked.appUrl);
508
+ return;
509
+ }
510
+
511
+ line('');
512
+ line(' Kryptheon Night Shift');
513
+ line(' I attack a copy of your database and tell you what got in.');
514
+
515
+ // Asking needs somewhere to ask. Piped into a script or run by a scheduler
516
+ // there is no keyboard, and a prompt written to nobody looks exactly like a
517
+ // program that has frozen.
518
+ const canAsk = Boolean(process.stdin.isTTY && process.stdout.isTTY);
519
+
520
+ let connection = process.env.KN_DATABASE_URL || '';
521
+ if (!connection) {
522
+ if (!canAsk) {
523
+ fail('');
524
+ fail(' I need a connection string and there is nobody here to ask.');
525
+ fail('');
526
+ fail(' Run me in a terminal window, or set KN_DATABASE_URL first.');
527
+ fail('');
528
+ process.exit(2);
529
+ }
530
+ connection = await intro.askForConnection(line);
531
+ }
532
+
533
+ // Everything that can be told from the string alone is told now, before a
534
+ // single packet goes anywhere. These are the mistakes a person makes on
535
+ // their first try, and Postgres describes every one of them badly.
536
+ const unusable = trouble.readConnectionString(connection);
537
+ if (unusable) {
538
+ block(unusable, fail);
539
+ process.exit(2);
540
+ }
541
+ connection = connection.trim().replace(/^["']|["']$/g, '');
542
+
543
+ const warning = trouble.poolerWarning(connection);
544
+ if (warning) block(warning);
545
+
546
+ // Not named: "public", or the one place the app's tables all are - found
547
+ // once connected (scanner.pickSchema). The consent screen says exactly that.
548
+ let target = asked.schema || 'public';
549
+
550
+ // The password is not shown back, but the address is - it is the part a
551
+ // person can check, and seeing their own project name appear is the first
552
+ // sign that any of this is working.
553
+ line('');
554
+ line(' Database: ' + trouble.withoutSecret(connection));
555
+
556
+ // Reading does not need permission; changing does. `status` and `night` only
557
+ // read what is already written down, so asking would be ceremony.
558
+ const needsAYes = asked.command === 'scan' || asked.command === 'install';
559
+
560
+ if (needsAYes && !asked.yes) {
561
+ if (!canAsk) {
562
+ fail('');
563
+ fail(' I will not connect to your database without being told yes, and');
564
+ fail(' there is nobody here to ask.');
565
+ fail('');
566
+ fail(' Add --yes if you meant to run this unattended.');
567
+ fail('');
568
+ process.exit(2);
569
+ }
570
+ // Two different screens, because they promise different things. The scan's
571
+ // ends "I do not leave anything behind", which is true of a scan and false
572
+ // of an install - and this is the one screen the product cannot be loose on.
573
+ block(asked.command === 'install'
574
+ ? intro.installConsentLines(installer.SCHEMA, target, installer.AT, reportToFromEnv())
575
+ : intro.consentLines(asked.schema || null));
576
+ const yes = await intro.askYesNo(' Go ahead? (y/n) ');
577
+ if (!yes) {
578
+ line('');
579
+ line(' Stopped. Nothing was connected to and nothing was changed.');
580
+ line('');
581
+ process.exit(0);
582
+ }
583
+ }
584
+
585
+ // Compared against last time whenever there IS a last time, without being
586
+ // asked.
587
+ //
588
+ // `--recheck` was a flag, and a flag is a thing somebody has to know about.
589
+ // The person this is for fixed what the report told them to fix and typed
590
+ // the same command again - and got a fresh report that said the same number
591
+ // of problems, with no word about which of them they had just closed. The
592
+ // tool knew. It had the file. It waited to be asked.
593
+ //
594
+ // The flag still works, because anything scripted may be passing it, and
595
+ // now it changes nothing: with an earlier run here, this always compares.
596
+ let file;
597
+ try {
598
+ file = scanner.lastRunFile(line);
599
+ } catch (err) {
600
+ // Nowhere to keep this run means nothing to compare the next one against,
601
+ // and a re-check with nothing to compare against is not an answer.
602
+ fail('');
603
+ fail(' Kryptheon keeps its last run for this project in ' + store.dirFor(process.cwd()));
604
+ fail(' and could not write there: ' + err.message);
605
+ fail(' Set KRYPTHEON_HOME to a folder you can write to, and run this again.');
606
+ fail('');
607
+ process.exit(2);
608
+ }
609
+ const before = scanner.loadLastRun(file);
610
+ if (asked.recheck && !before) {
611
+ // Asked for explicitly and impossible, which is worth saying. Arriving
612
+ // here by accident is not: without the flag, no earlier run simply means
613
+ // this is the first one.
614
+ fail('');
615
+ fail(' There is no earlier run in this folder to compare against.');
616
+ fail('');
617
+ fail(' A re-check proves a fix worked by running the same attacks again and');
618
+ fail(' comparing. With nothing to compare against it would report every');
619
+ fail(' problem as new, which would look like an answer and would not be one.');
620
+ fail('');
621
+ fail(' Run it once first, and it will compare by itself from then on.');
622
+ fail('');
623
+ process.exit(2);
624
+ }
625
+
626
+ line('');
627
+ line(' Connecting ...');
628
+
629
+ let client;
630
+ try {
631
+ client = new Client(howToConnect(connection));
632
+ await client.connect();
633
+ } catch (err) {
634
+ block(trouble.explain(err, connection), fail);
635
+ process.exit(2);
636
+ }
637
+
638
+ line(' Connected.');
639
+ line('');
640
+
641
+ // The other three doors. Each one ends the command; only `scan` falls
642
+ // through to the attack below.
643
+ if (asked.command !== 'scan') {
644
+ try {
645
+ process.exitCode = await runVerb(asked.command, client, target);
646
+ } catch (err) {
647
+ block(trouble.explain(err, connection, { connected: true }), fail);
648
+ process.exitCode = 2;
649
+ } finally {
650
+ await client.end().catch(() => {});
651
+ }
652
+ return;
653
+ }
654
+
655
+ try {
656
+ if (!asked.schema) {
657
+ const picked = await scanner.pickSchema(client, installer.SCHEMA);
658
+ if (picked.chosen) {
659
+ target = picked.schema;
660
+ line(' Your app\'s tables are all in "' + target + '", not in "public", so that is where I');
661
+ line(' am looking. To look somewhere else: --schema NAME');
662
+ line('');
663
+ }
664
+ }
665
+ const result = await scanner.scan(client, target, {
666
+ // Two requests arriving at the same instant cannot be faked down one
667
+ // connection, so the collision attack is handed a way to open its own.
668
+ // This is the one attack the nightly installer will never be able to
669
+ // run, and the report says so when it cannot.
670
+ openSession: async () => {
671
+ const extra = new Client(howToConnect(connection));
672
+ await extra.connect();
673
+ return extra;
674
+ },
675
+ });
676
+
677
+ if (!before) {
678
+ scanner.report(result);
679
+ scanner.saveRun(file, result);
680
+ process.exitCode = scanner.exitCodeFor(result);
681
+ } else {
682
+ const verdict = recheck.compare(before, result);
683
+ recheck.describe(verdict).forEach(line);
684
+ recheck.badgeLines(verdict, result.attacksRun || 0).forEach(line);
685
+ // The report ends with what was not tested; with nothing left to report
686
+ // that list still has to be printed, or the re-check goes quiet about it.
687
+ if (result.findings.length) scanner.report(result);
688
+ else scanner.notTestedLines(result).forEach(line);
689
+ scanner.saveRun(file, result);
690
+ // Two different databases: the verdict is a comparison, so the exit
691
+ // code is this run's own answer, the same as a first run.
692
+ process.exitCode = result.stopped ? 2 : verdict.elsewhere ? scanner.exitCodeFor(result) : verdict.allClear ? 0 : 1;
693
+ }
694
+
695
+ // And then the only other thing worth doing, offered rather than
696
+ // documented.
697
+ //
698
+ // `install` was a verb, and a verb is a thing somebody has to find out
699
+ // about. Nobody who clicked Deploy in Lovable is going to read a list of
700
+ // subcommands - they will run this once, get their answer, and never
701
+ // think about it again, which is exactly the app that is unprotected
702
+ // three months later. The tool already knows whether the nightly run is
703
+ // there. So it asks.
704
+ // Whatever the nightly run last said is kept for the dashboard too, so
705
+ // the page shows both answers and says which is which.
706
+ await readAndKeepTheNight(client);
707
+ if (!result.stopped) await offerTheNight(client, target, canAsk);
708
+ } catch (err) {
709
+ // Anything that went wrong mid-scan. The copy has already been dropped by
710
+ // the `finally` inside the scan itself, so there is nothing to clean up
711
+ // here - only something to say.
712
+ block(trouble.explain(err, connection, { connected: true }), fail);
713
+ process.exitCode = 2;
714
+ } finally {
715
+ await client.end().catch(() => {});
716
+ }
717
+ }
718
+
719
+ // Ctrl-C during the attack leaves a copy behind, because the process dies
720
+ // before the `finally` that drops it runs. The next run sweeps it up, but the
721
+ // person deserves to know it is there rather than finding it themselves.
722
+ process.on('SIGINT', () => {
723
+ line('');
724
+ line('');
725
+ line(' Stopped.');
726
+ line('');
727
+ line(' If I had already made the temporary copy, it is still in your');
728
+ line(' database. Run me again and I will delete it before I start.');
729
+ line('');
730
+ process.exit(130);
731
+ });
732
+
733
+ main().catch((err) => {
734
+ block(trouble.explain(err, process.env.KN_DATABASE_URL || ''), fail);
735
+ process.exit(2);
736
+ });