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 +33 -0
- package/bin/kryptheon-night.js +169 -5
- package/engine.sql +2484 -0
- package/installer.js +224 -0
- package/intro.js +52 -0
- package/package.json +9 -4
- package/scan.js +38 -7
- package/sqlengine.js +200 -0
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 |
|
package/bin/kryptheon-night.js
CHANGED
|
@@ -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 = {
|
|
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
|
-
//
|
|
73
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|