kryptheon-night 0.1.3 → 0.1.4

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
@@ -87,6 +87,39 @@ not the same as a problem that was *fixed* — if a table could not be tested
87
87
  this time, or is no longer there, it says so rather than crediting you with a
88
88
  fix. Close everything, with nothing left untested, and it hands out a badge.
89
89
 
90
+ ## Or let it run every night, on its own
91
+
92
+ ```
93
+ npx kryptheon-night install
94
+ ```
95
+
96
+ This one **stays**, and it tells you so before it does anything. It puts the
97
+ checking functions into a schema called `kryptheon`, adds `pg_cron` and
98
+ `pg_net` if they are not already there, and schedules one job at 3am. Then
99
+ every night it does what the command above does — copies the shape of your
100
+ tables, attacks the copy, writes down what got through, deletes the copy.
101
+
102
+ ```
103
+ npx kryptheon-night night read back the last night it ran
104
+ npx kryptheon-night status is it installed, and is it running
105
+ npx kryptheon-night uninstall take it all out again
106
+ ```
107
+
108
+ **Nothing leaves your database**, because the checking happens inside it — the
109
+ connection string is not stored anywhere and nothing is uploaded. It is also
110
+ much faster for the same reason: on a real project the command line took 2
111
+ minutes 16 seconds and the nightly run took 1 second, and both found the same
112
+ five problems.
113
+
114
+ **One thing it cannot do.** Racing two requests against each other — the "can
115
+ this exist twice" question — needs two connections at the same instant, and
116
+ nothing living inside a database has them. The nightly report names every
117
+ column it could not race. Running the command by hand still tests them
118
+ properly.
119
+
120
+ `uninstall` removes the job, the schema, and any extension it had to add, and
121
+ leaves alone anything that was already there.
122
+
90
123
  ## Three answers, three exit codes
91
124
 
92
125
  | exit | meaning |
@@ -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');
@@ -43,6 +45,17 @@ function usage() {
43
45
  '',
44
46
  ' npx kryptheon-night',
45
47
  '',
48
+ ' Or let it do the same thing every night, from inside your database:',
49
+ '',
50
+ ' npx kryptheon-night install set up the nightly run',
51
+ ' npx kryptheon-night night read back the last night it ran',
52
+ ' npx kryptheon-night status is it installed, and is it running',
53
+ ' npx kryptheon-night uninstall take it all out again',
54
+ '',
55
+ ' The nightly run needs pg_cron and pg_net. Supabase has both. It cannot',
56
+ ' race two requests at once, so it is weaker than this command for the',
57
+ ' "can this exist twice" question, and its report says which columns.',
58
+ '',
46
59
  ' Options, none of them necessary:',
47
60
  '',
48
61
  ' --recheck run the same attacks again after a fix, and say',
@@ -58,9 +71,17 @@ function usage() {
58
71
  ];
59
72
  }
60
73
 
74
+ // The things this can be asked to do besides scan. Kept to verbs nobody
75
+ // names a schema: `public` is the schema almost everybody has, and a bare
76
+ // word that is not one of these is still read as a schema so that
77
+ // `kryptheon-night public` keeps working.
78
+ const VERBS = ['install', 'uninstall', 'status', 'night'];
79
+
61
80
  /** What was asked for, and what was left to the default. */
62
81
  function readArgs(argv) {
63
- const asked = { schema: null, recheck: false, yes: false, help: false, unknown: [] };
82
+ const asked = {
83
+ command: 'scan', schema: null, recheck: false, yes: false, help: false, unknown: [],
84
+ };
64
85
  for (let i = 0; i < argv.length; i++) {
65
86
  const arg = argv[i];
66
87
  if (arg === '--recheck') asked.recheck = true;
@@ -69,14 +90,134 @@ function readArgs(argv) {
69
90
  else if (arg === '--schema') asked.schema = argv[++i] || null;
70
91
  else if (arg.startsWith('--schema=')) asked.schema = arg.slice('--schema='.length);
71
92
  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.
93
+ // The first bare word is a verb if it is one, and otherwise the schema -
94
+ // so that somebody who has read the old instructions and types
95
+ // `kryptheon-night public` is not told off. Anybody whose schema really
96
+ // is called `status` can say so with --schema.
97
+ else if (asked.command === 'scan' && !asked.schema && VERBS.includes(arg)) asked.command = arg;
74
98
  else if (!asked.schema) asked.schema = arg;
75
99
  else asked.unknown.push(arg);
76
100
  }
77
101
  return asked;
78
102
  }
79
103
 
104
+ /**
105
+ * The three doors that are not a scan.
106
+ *
107
+ * Each returns the exit code, because anything automating this reads those
108
+ * and not the words: 0 for nothing got through or nothing to say, 1 for
109
+ * something did, 2 for could not.
110
+ */
111
+ async function runVerb(command, client, target) {
112
+ if (command === 'status') {
113
+ const where = await installer.status(client, {});
114
+ if (!where) {
115
+ line(' The nightly run is not installed in this database.');
116
+ line('');
117
+ line(' npx kryptheon-night install would set it up.');
118
+ line('');
119
+ return 0;
120
+ }
121
+ line(' Installed, and watching "' + where.source + '".');
122
+ line('');
123
+ if (where.job) {
124
+ line(' job ' + where.job.jobname + ' ' + where.job.schedule +
125
+ (where.job.active ? '' : ' (NOT ACTIVE)'));
126
+ } else {
127
+ // The one failure that looks like success from every other angle: the
128
+ // schema is there, the functions are there, and nothing is running.
129
+ line(' job MISSING - nothing is scheduled, so nothing runs');
130
+ }
131
+ line(' schema ' + where.schema);
132
+ line(' since ' + new Date(where.installed_at).toISOString().slice(0, 16).replace('T', ' '));
133
+ line('');
134
+ const { rows } = await client.query(
135
+ 'SELECT count(*)::int AS n, max(ran_at) AS last FROM ' +
136
+ '"' + String(where.schema).split('"').join('""') + '".runs',
137
+ ).catch(() => ({ rows: [{ n: 0, last: null }] }));
138
+ line(' runs ' + rows[0].n +
139
+ (rows[0].last ? ' last ' + new Date(rows[0].last).toISOString().slice(0, 16).replace('T', ' ') : ''));
140
+ line('');
141
+ return where.job ? 0 : 1;
142
+ }
143
+
144
+ if (command === 'install') {
145
+ line(' Installing ...');
146
+ const done = await installer.install(client, { source: target });
147
+ line('');
148
+ line(' Installed. It will run at ' + done.at + ', watching "' + done.source + '".');
149
+ line('');
150
+ if (done.made.extensions.length) {
151
+ line(' I had to add: ' + done.made.extensions.join(', ') + '.');
152
+ line(' Removing Kryptheon will take those back out again.');
153
+ line('');
154
+ }
155
+ line(' Tomorrow morning: npx kryptheon-night night');
156
+ line(' To remove it: npx kryptheon-night uninstall');
157
+ line('');
158
+ return 0;
159
+ }
160
+
161
+ if (command === 'uninstall') {
162
+ const was = await installer.status(client, {});
163
+ if (!was) {
164
+ line(' There is nothing installed here to remove.');
165
+ line('');
166
+ return 0;
167
+ }
168
+ const removed = await installer.uninstall(client, {});
169
+ line(' Removed.');
170
+ line('');
171
+ line(' the nightly job ' + (removed.job ? 'gone' : 'there was none'));
172
+ line(' the schema ' + (removed.schema ? 'gone' : 'left, it was not mine to drop'));
173
+ line(' extensions ' + (removed.extensions.length
174
+ ? removed.extensions.join(', ') + ' - the ones I added'
175
+ : 'none removed; they were here before me'));
176
+ line('');
177
+ return 0;
178
+ }
179
+
180
+ if (command === 'night') {
181
+ const where = await installer.status(client, {});
182
+ if (!where) {
183
+ line(' The nightly run is not installed in this database, so there is');
184
+ line(' nothing to read back.');
185
+ line('');
186
+ line(' npx kryptheon-night would scan it now.');
187
+ line(' npx kryptheon-night install would set up the nightly run.');
188
+ line('');
189
+ return 2;
190
+ }
191
+ const quoted = '"' + String(where.schema).split('"').join('""') + '"';
192
+ const { rows } = await client.query(
193
+ 'SELECT ran_at, source, stopped, attacks_run, findings, not_checked ' +
194
+ 'FROM ' + quoted + '.runs ORDER BY ran_at DESC LIMIT 1',
195
+ );
196
+ if (!rows.length) {
197
+ // Installed and never run is not the same as run and found nothing, and
198
+ // saying "nothing got through" here would be the worst sentence in the
199
+ // product printed about a night that never happened.
200
+ line(' It is installed, and it has not run yet.');
201
+ line('');
202
+ line(' The first run is at ' + (where.job ? where.job.schedule : 'whenever it is scheduled') + '.');
203
+ line('');
204
+ return 0;
205
+ }
206
+ const run = rows[0];
207
+ line(' From the night of ' + new Date(run.ran_at).toISOString().slice(0, 16).replace('T', ' ') +
208
+ ', on "' + run.source + '":');
209
+ scanner.report({
210
+ stopped: run.stopped,
211
+ attacksRun: run.attacks_run,
212
+ notChecked: run.not_checked || [],
213
+ findings: finding.describeAll(run.findings || []),
214
+ });
215
+ return run.stopped ? 2 : (run.findings || []).length ? 1 : 0;
216
+ }
217
+
218
+ throw new Error('there is no command called ' + command);
219
+ }
220
+
80
221
  /** Prints a block of plain lines with the indent the rest of the report uses. */
81
222
  function block(lines, write) {
82
223
  (write || line)('');
@@ -141,7 +282,11 @@ async function main() {
141
282
  line('');
142
283
  line(' Database: ' + trouble.withoutSecret(connection));
143
284
 
144
- if (!asked.yes) {
285
+ // Reading does not need permission; changing does. `status` and `night` only
286
+ // read what is already written down, so asking would be ceremony.
287
+ const needsAYes = asked.command === 'scan' || asked.command === 'install';
288
+
289
+ if (needsAYes && !asked.yes) {
145
290
  if (!canAsk) {
146
291
  fail('');
147
292
  fail(' I will not connect to your database without being told yes, and');
@@ -151,7 +296,12 @@ async function main() {
151
296
  fail('');
152
297
  process.exit(2);
153
298
  }
154
- block(intro.consentLines(target));
299
+ // Two different screens, because they promise different things. The scan's
300
+ // ends "I do not leave anything behind", which is true of a scan and false
301
+ // of an install - and this is the one screen the product cannot be loose on.
302
+ block(asked.command === 'install'
303
+ ? intro.installConsentLines(installer.SCHEMA, target, installer.AT)
304
+ : intro.consentLines(target));
155
305
  const yes = await intro.askYesNo(' Go ahead? (y/n) ');
156
306
  if (!yes) {
157
307
  line('');
@@ -191,6 +341,20 @@ async function main() {
191
341
  line(' Connected.');
192
342
  line('');
193
343
 
344
+ // The other three doors. Each one ends the command; only `scan` falls
345
+ // through to the attack below.
346
+ if (asked.command !== 'scan') {
347
+ try {
348
+ process.exitCode = await runVerb(asked.command, client, target);
349
+ } catch (err) {
350
+ block(trouble.explain(err, connection), fail);
351
+ process.exitCode = 2;
352
+ } finally {
353
+ await client.end().catch(() => {});
354
+ }
355
+ return;
356
+ }
357
+
194
358
  try {
195
359
  const result = await scanner.scan(client, target, {
196
360
  // Two requests arriving at the same instant cannot be faked down one