kryptheon-night 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -76,16 +76,55 @@ attacks, this time, lost*.
76
76
 
77
77
  ## Prove the fix worked
78
78
 
79
- Paste the fix into Lovable or Cursor, let it deploy, then:
79
+ Paste the fix into Lovable or Cursor, let it deploy, then run the same command
80
+ again:
80
81
 
81
82
  ```
82
- npx kryptheon-night --recheck
83
+ npx kryptheon-night
84
+ ```
85
+
86
+ It compares by itself — there is no flag to remember. A problem that
87
+ *disappeared* is not the same as a problem that was *fixed*: if a table could
88
+ not be tested this time, or is no longer there, it says so rather than
89
+ crediting you with a fix. Close everything, with nothing left untested, and it
90
+ hands out a badge.
91
+
92
+ ## Or let it run every night, on its own
93
+
94
+ You do not have to go looking for this — the command above offers it once the
95
+ report is on the screen, and tells you if it is already running. If you would
96
+ rather just say so:
97
+
98
+ ```
99
+ npx kryptheon-night install
83
100
  ```
84
101
 
85
- It runs the same attacks again and compares. A problem that *disappeared* is
86
- not the same as a problem that was *fixed* — if a table could not be tested
87
- this time, or is no longer there, it says so rather than crediting you with a
88
- fix. Close everything, with nothing left untested, and it hands out a badge.
102
+ This one **stays**, and it tells you so before it does anything. It puts the
103
+ checking functions into a schema called `kryptheon`, adds `pg_cron` and
104
+ `pg_net` if they are not already there, and schedules one job at 3am. Then
105
+ every night it does what the command above does — copies the shape of your
106
+ tables, attacks the copy, writes down what got through, deletes the copy.
107
+
108
+ ```
109
+ npx kryptheon-night night read back the last night it ran
110
+ npx kryptheon-night status is it installed, and is it running
111
+ npx kryptheon-night uninstall take it all out again
112
+ ```
113
+
114
+ **Nothing leaves your database**, because the checking happens inside it — the
115
+ connection string is not stored anywhere and nothing is uploaded. It is also
116
+ much faster for the same reason: on a real project the command line took 2
117
+ minutes 16 seconds and the nightly run took 1 second, and both found the same
118
+ five problems.
119
+
120
+ **One thing it cannot do.** Racing two requests against each other — the "can
121
+ this exist twice" question — needs two connections at the same instant, and
122
+ nothing living inside a database has them. The nightly report names every
123
+ column it could not race. Running the command by hand still tests them
124
+ properly.
125
+
126
+ `uninstall` removes the job, the schema, and any extension it had to add, and
127
+ leaves alone anything that was already there.
89
128
 
90
129
  ## Three answers, three exit codes
91
130
 
@@ -30,6 +30,8 @@ const recheck = require('../recheck.js');
30
30
  const intro = require('../intro.js');
31
31
  const trouble = require('../trouble.js');
32
32
  const { howToConnect } = require('../connect.js');
33
+ const installer = require('../installer.js');
34
+ const finding = require('../finding.js');
33
35
 
34
36
  const line = (text) => process.stdout.write(text + '\n');
35
37
  const fail = (text) => process.stderr.write(text + '\n');
@@ -39,17 +41,33 @@ function usage() {
39
41
  '',
40
42
  ' kryptheon-night - attacks a copy of your database and tells you what got in.',
41
43
  '',
42
- ' Run it with nothing and it will ask you for what it needs:',
44
+ ' There is one command. It asks for what it needs:',
43
45
  '',
44
46
  ' npx kryptheon-night',
45
47
  '',
48
+ ' Run it again after you fix something and it tells you what is actually',
49
+ ' closed - you do not have to ask it to. It also offers to do the same',
50
+ ' check every night from inside your database, and says so if it already',
51
+ ' is. Nothing below is needed to use this.',
52
+ '',
53
+ ' For scripts, and for people who like knowing:',
54
+ '',
55
+ ' npx kryptheon-night night read back the last night it ran',
56
+ ' npx kryptheon-night status is it installed, and is it running',
57
+ ' npx kryptheon-night install set the nightly run up without being asked',
58
+ ' npx kryptheon-night uninstall take it all out again',
59
+ '',
60
+ ' The nightly run needs pg_cron and pg_net. Supabase has both. It cannot',
61
+ ' race two requests at once, so it is weaker than this command for the',
62
+ ' "can this exist twice" question, and its report says which columns.',
63
+ '',
46
64
  ' Options, none of them necessary:',
47
65
  '',
48
- ' --recheck run the same attacks again after a fix, and say',
49
- ' which problems are actually closed',
50
66
  ' --schema NAME the part of the database your app lives in.',
51
67
  ' Leave it out; it is "public" for almost everyone',
52
68
  ' --yes skip the "may I?" question. For scripts only',
69
+ ' --recheck nothing any more - comparing against the last run',
70
+ ' happens by itself. Kept so scripts do not break',
53
71
  ' --help this',
54
72
  '',
55
73
  ' The connection string can be put in KN_DATABASE_URL instead of being',
@@ -58,9 +76,17 @@ function usage() {
58
76
  ];
59
77
  }
60
78
 
79
+ // The things this can be asked to do besides scan. Kept to verbs nobody
80
+ // names a schema: `public` is the schema almost everybody has, and a bare
81
+ // word that is not one of these is still read as a schema so that
82
+ // `kryptheon-night public` keeps working.
83
+ const VERBS = ['install', 'uninstall', 'status', 'night'];
84
+
61
85
  /** What was asked for, and what was left to the default. */
62
86
  function readArgs(argv) {
63
- const asked = { schema: null, recheck: false, yes: false, help: false, unknown: [] };
87
+ const asked = {
88
+ command: 'scan', schema: null, recheck: false, yes: false, help: false, unknown: [],
89
+ };
64
90
  for (let i = 0; i < argv.length; i++) {
65
91
  const arg = argv[i];
66
92
  if (arg === '--recheck') asked.recheck = true;
@@ -69,14 +95,233 @@ function readArgs(argv) {
69
95
  else if (arg === '--schema') asked.schema = argv[++i] || null;
70
96
  else if (arg.startsWith('--schema=')) asked.schema = arg.slice('--schema='.length);
71
97
  else if (arg.startsWith('-')) asked.unknown.push(arg);
72
- // A bare word is the schema, so that somebody who has read the old
73
- // instructions and types `kryptheon-night public` is not told off.
98
+ // The first bare word is a verb if it is one, and otherwise the schema -
99
+ // so that somebody who has read the old instructions and types
100
+ // `kryptheon-night public` is not told off. Anybody whose schema really
101
+ // is called `status` can say so with --schema.
102
+ else if (asked.command === 'scan' && !asked.schema && VERBS.includes(arg)) asked.command = arg;
74
103
  else if (!asked.schema) asked.schema = arg;
75
104
  else asked.unknown.push(arg);
76
105
  }
77
106
  return asked;
78
107
  }
79
108
 
109
+ /**
110
+ * The three doors that are not a scan.
111
+ *
112
+ * Each returns the exit code, because anything automating this reads those
113
+ * and not the words: 0 for nothing got through or nothing to say, 1 for
114
+ * something did, 2 for could not.
115
+ */
116
+ async function runVerb(command, client, target) {
117
+ if (command === 'status') {
118
+ const where = await installer.status(client, {});
119
+ if (!where) {
120
+ line(' The nightly run is not installed in this database.');
121
+ line('');
122
+ line(' npx kryptheon-night install would set it up.');
123
+ line('');
124
+ return 0;
125
+ }
126
+ line(' Installed, and watching "' + where.source + '".');
127
+ line('');
128
+ if (where.job) {
129
+ line(' job ' + where.job.jobname + ' ' + where.job.schedule +
130
+ (where.job.active ? '' : ' (NOT ACTIVE)'));
131
+ } else {
132
+ // The one failure that looks like success from every other angle: the
133
+ // schema is there, the functions are there, and nothing is running.
134
+ line(' job MISSING - nothing is scheduled, so nothing runs');
135
+ }
136
+ line(' schema ' + where.schema);
137
+ line(' since ' + new Date(where.installed_at).toISOString().slice(0, 16).replace('T', ' '));
138
+ line('');
139
+ const { rows } = await client.query(
140
+ 'SELECT count(*)::int AS n, max(ran_at) AS last FROM ' +
141
+ '"' + String(where.schema).split('"').join('""') + '".runs',
142
+ ).catch(() => ({ rows: [{ n: 0, last: null }] }));
143
+ line(' runs ' + rows[0].n +
144
+ (rows[0].last ? ' last ' + new Date(rows[0].last).toISOString().slice(0, 16).replace('T', ' ') : ''));
145
+ line('');
146
+ return where.job ? 0 : 1;
147
+ }
148
+
149
+ if (command === 'install') {
150
+ line(' Installing ...');
151
+ const done = await installer.install(client, { source: target });
152
+ line('');
153
+ line(' Installed. It will run at ' + done.at + ', watching "' + done.source + '".');
154
+ line('');
155
+ if (done.made.extensions.length) {
156
+ line(' I had to add: ' + done.made.extensions.join(', ') + '.');
157
+ line(' Removing Kryptheon will take those back out again.');
158
+ line('');
159
+ }
160
+ line(' Tomorrow morning: npx kryptheon-night night');
161
+ line(' To remove it: npx kryptheon-night uninstall');
162
+ line('');
163
+ return 0;
164
+ }
165
+
166
+ if (command === 'uninstall') {
167
+ const was = await installer.status(client, {});
168
+ if (!was) {
169
+ line(' There is nothing installed here to remove.');
170
+ line('');
171
+ return 0;
172
+ }
173
+ const removed = await installer.uninstall(client, {});
174
+ line(' Removed.');
175
+ line('');
176
+ line(' the nightly job ' + (removed.job ? 'gone' : 'there was none'));
177
+ line(' the schema ' + (removed.schema ? 'gone' : 'left, it was not mine to drop'));
178
+ line(' extensions ' + (removed.extensions.length
179
+ ? removed.extensions.join(', ') + ' - the ones I added'
180
+ : 'none removed; they were here before me'));
181
+ line('');
182
+ return 0;
183
+ }
184
+
185
+ if (command === 'night') {
186
+ const where = await installer.status(client, {});
187
+ if (!where) {
188
+ line(' The nightly run is not installed in this database, so there is');
189
+ line(' nothing to read back.');
190
+ line('');
191
+ line(' npx kryptheon-night would scan it now.');
192
+ line(' npx kryptheon-night install would set up the nightly run.');
193
+ line('');
194
+ return 2;
195
+ }
196
+ const quoted = '"' + String(where.schema).split('"').join('""') + '"';
197
+ const { rows } = await client.query(
198
+ 'SELECT ran_at, source, stopped, attacks_run, findings, not_checked ' +
199
+ 'FROM ' + quoted + '.runs ORDER BY ran_at DESC LIMIT 1',
200
+ );
201
+ if (!rows.length) {
202
+ // Installed and never run is not the same as run and found nothing, and
203
+ // saying "nothing got through" here would be the worst sentence in the
204
+ // product printed about a night that never happened.
205
+ line(' It is installed, and it has not run yet.');
206
+ line('');
207
+ line(' The first run is at ' + (where.job ? where.job.schedule : 'whenever it is scheduled') + '.');
208
+ line('');
209
+ return 0;
210
+ }
211
+ const run = rows[0];
212
+ line(' From the night of ' + new Date(run.ran_at).toISOString().slice(0, 16).replace('T', ' ') +
213
+ ', on "' + run.source + '":');
214
+ scanner.report({
215
+ stopped: run.stopped,
216
+ attacksRun: run.attacks_run,
217
+ notChecked: run.not_checked || [],
218
+ findings: finding.describeAll(run.findings || []),
219
+ });
220
+ return run.stopped ? 2 : (run.findings || []).length ? 1 : 0;
221
+ }
222
+
223
+ throw new Error('there is no command called ' + command);
224
+ }
225
+
226
+ /**
227
+ * After the report: say what the nightly run is doing, or offer it.
228
+ *
229
+ * Three states and each says something different. Installed and running gets
230
+ * one line, because it is working and nobody needs a paragraph about it.
231
+ * Installed with no job is the quiet failure - the schema is there, the
232
+ * functions are there, and nothing runs - so it says so plainly. Not
233
+ * installed gets the offer.
234
+ *
235
+ * Nothing here can make the scan fail. Whatever happens, the person has
236
+ * already been given their answer, and losing it to an error about a nightly
237
+ * job they never asked for would be the tool wasting the only thing it did.
238
+ */
239
+ async function offerTheNight(client, target, canAsk) {
240
+ let where = null;
241
+ try {
242
+ where = await installer.status(client, {});
243
+ } catch (err) {
244
+ return;
245
+ }
246
+
247
+ if (where && where.job) {
248
+ line(' I am also checking this every night, at ' + where.job.schedule + '.');
249
+ line(' Read the last one with: npx kryptheon-night night');
250
+ line('');
251
+ return;
252
+ }
253
+
254
+ if (where && !where.job) {
255
+ line(' The nightly check is installed here but nothing is scheduled, so it');
256
+ line(' is not running. Setting it up again would fix that:');
257
+ line('');
258
+ line(' npx kryptheon-night install');
259
+ line('');
260
+ return;
261
+ }
262
+
263
+ if (!canAsk) {
264
+ // Nobody to ask. Said once, quietly, rather than nagging a log file.
265
+ line(' This can also run every night by itself: npx kryptheon-night install');
266
+ line('');
267
+ return;
268
+ }
269
+
270
+ const can = await installer.extensionState(client, 'pg_net').catch(() => ({ available: false }));
271
+ const cron = await installer.extensionState(client, 'pg_cron').catch(() => ({ available: false }));
272
+ if (!can.available || !cron.available) {
273
+ // Not offered where it cannot happen. An offer that fails when accepted
274
+ // is worse than no offer.
275
+ return;
276
+ }
277
+
278
+ line(' One more thing.');
279
+ line('');
280
+ line(' What you just read is true about your app right now. It stops being');
281
+ line(' true the next time anybody changes it - and that is the day nobody');
282
+ line(' runs this. I can do exactly the same check every night, from inside');
283
+ line(' your database, and have the answer waiting.');
284
+ line('');
285
+ const yes = await intro.askYesNo(' Set that up? (y/n) ');
286
+ if (!yes) {
287
+ line('');
288
+ line(' Fine. It is here if you change your mind:');
289
+ line(' npx kryptheon-night install');
290
+ line('');
291
+ return;
292
+ }
293
+
294
+ block(intro.installConsentLines(installer.SCHEMA, target, installer.AT));
295
+ const sure = await intro.askYesNo(' Go ahead? (y/n) ');
296
+ if (!sure) {
297
+ line('');
298
+ line(' Left alone. Nothing was installed.');
299
+ line('');
300
+ return;
301
+ }
302
+
303
+ try {
304
+ const done = await installer.install(client, { source: target });
305
+ line('');
306
+ line(' Done. It will run at ' + done.at + ', watching "' + done.source + '".');
307
+ if (done.made.extensions.length) {
308
+ line(' I added ' + done.made.extensions.join(' and ') + '; uninstall takes them back out.');
309
+ }
310
+ line('');
311
+ line(' Tomorrow: npx kryptheon-night night');
312
+ line(' To remove: npx kryptheon-night uninstall');
313
+ line('');
314
+ } catch (err) {
315
+ // The scan still stands. Say what failed and leave it at that.
316
+ line('');
317
+ line(' I could not set up the nightly run: ' + String(err.message).split('\n')[0]);
318
+ line('');
319
+ line(' The report above is still good. Nothing was left half-installed.');
320
+ line('');
321
+ await installer.uninstall(client, {}).catch(() => {});
322
+ }
323
+ }
324
+
80
325
  /** Prints a block of plain lines with the indent the rest of the report uses. */
81
326
  function block(lines, write) {
82
327
  (write || line)('');
@@ -141,7 +386,11 @@ async function main() {
141
386
  line('');
142
387
  line(' Database: ' + trouble.withoutSecret(connection));
143
388
 
144
- if (!asked.yes) {
389
+ // Reading does not need permission; changing does. `status` and `night` only
390
+ // read what is already written down, so asking would be ceremony.
391
+ const needsAYes = asked.command === 'scan' || asked.command === 'install';
392
+
393
+ if (needsAYes && !asked.yes) {
145
394
  if (!canAsk) {
146
395
  fail('');
147
396
  fail(' I will not connect to your database without being told yes, and');
@@ -151,7 +400,12 @@ async function main() {
151
400
  fail('');
152
401
  process.exit(2);
153
402
  }
154
- block(intro.consentLines(target));
403
+ // Two different screens, because they promise different things. The scan's
404
+ // ends "I do not leave anything behind", which is true of a scan and false
405
+ // of an install - and this is the one screen the product cannot be loose on.
406
+ block(asked.command === 'install'
407
+ ? intro.installConsentLines(installer.SCHEMA, target, installer.AT)
408
+ : intro.consentLines(target));
155
409
  const yes = await intro.askYesNo(' Go ahead? (y/n) ');
156
410
  if (!yes) {
157
411
  line('');
@@ -161,17 +415,31 @@ async function main() {
161
415
  }
162
416
  }
163
417
 
418
+ // Compared against last time whenever there IS a last time, without being
419
+ // asked.
420
+ //
421
+ // `--recheck` was a flag, and a flag is a thing somebody has to know about.
422
+ // The person this is for fixed what the report told them to fix and typed
423
+ // the same command again - and got a fresh report that said the same number
424
+ // of problems, with no word about which of them they had just closed. The
425
+ // tool knew. It had the file. It waited to be asked.
426
+ //
427
+ // The flag still works, because anything scripted may be passing it, and
428
+ // now it changes nothing: with an earlier run here, this always compares.
164
429
  const file = path.resolve(scanner.LAST_RUN);
165
- const before = asked.recheck ? scanner.loadLastRun(file) : null;
430
+ const before = scanner.loadLastRun(file);
166
431
  if (asked.recheck && !before) {
432
+ // Asked for explicitly and impossible, which is worth saying. Arriving
433
+ // here by accident is not: without the flag, no earlier run simply means
434
+ // this is the first one.
167
435
  fail('');
168
436
  fail(' There is no earlier run in this folder to compare against.');
169
437
  fail('');
170
- fail(' --recheck proves a fix worked by running the same attacks again and');
438
+ fail(' A re-check proves a fix worked by running the same attacks again and');
171
439
  fail(' comparing. With nothing to compare against it would report every');
172
440
  fail(' problem as new, which would look like an answer and would not be one.');
173
441
  fail('');
174
- fail(' Run it without --recheck first.');
442
+ fail(' Run it once first, and it will compare by itself from then on.');
175
443
  fail('');
176
444
  process.exit(2);
177
445
  }
@@ -191,6 +459,20 @@ async function main() {
191
459
  line(' Connected.');
192
460
  line('');
193
461
 
462
+ // The other three doors. Each one ends the command; only `scan` falls
463
+ // through to the attack below.
464
+ if (asked.command !== 'scan') {
465
+ try {
466
+ process.exitCode = await runVerb(asked.command, client, target);
467
+ } catch (err) {
468
+ block(trouble.explain(err, connection), fail);
469
+ process.exitCode = 2;
470
+ } finally {
471
+ await client.end().catch(() => {});
472
+ }
473
+ return;
474
+ }
475
+
194
476
  try {
195
477
  const result = await scanner.scan(client, target, {
196
478
  // Two requests arriving at the same instant cannot be faked down one
@@ -208,15 +490,25 @@ async function main() {
208
490
  scanner.report(result);
209
491
  scanner.saveRun(file, result);
210
492
  process.exitCode = result.stopped ? 2 : result.findings.length ? 1 : 0;
211
- return;
493
+ } else {
494
+ const verdict = recheck.compare(before, result);
495
+ recheck.describe(verdict).forEach(line);
496
+ recheck.badgeLines(verdict, result.attacksRun || 0).forEach(line);
497
+ if (result.findings.length) scanner.report(result);
498
+ scanner.saveRun(file, result);
499
+ process.exitCode = result.stopped ? 2 : verdict.allClear ? 0 : 1;
212
500
  }
213
501
 
214
- const verdict = recheck.compare(before, result);
215
- recheck.describe(verdict).forEach(line);
216
- recheck.badgeLines(verdict, result.attacksRun || 0).forEach(line);
217
- if (result.findings.length) scanner.report(result);
218
- scanner.saveRun(file, result);
219
- process.exitCode = result.stopped ? 2 : verdict.allClear ? 0 : 1;
502
+ // And then the only other thing worth doing, offered rather than
503
+ // documented.
504
+ //
505
+ // `install` was a verb, and a verb is a thing somebody has to find out
506
+ // about. Nobody who clicked Deploy in Lovable is going to read a list of
507
+ // subcommands - they will run this once, get their answer, and never
508
+ // think about it again, which is exactly the app that is unprotected
509
+ // three months later. The tool already knows whether the nightly run is
510
+ // there. So it asks.
511
+ if (!result.stopped) await offerTheNight(client, target, canAsk);
220
512
  } catch (err) {
221
513
  // Anything that went wrong mid-scan. The copy has already been dropped by
222
514
  // the `finally` inside the scan itself, so there is nothing to clean up