kryptheon-night 0.1.29 → 0.1.31
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 +17 -0
- package/bin/kryptheon-night.js +33 -5
- package/engine.sql +68 -0
- package/installer.js +296 -2
- package/intro.js +46 -9
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -149,6 +149,15 @@ much faster for the same reason: on a real project the command line took 2
|
|
|
149
149
|
minutes 16 seconds and the nightly run took 1 second, and both found the same
|
|
150
150
|
five problems.
|
|
151
151
|
|
|
152
|
+
**The one exception is one you ask for.** On Pro or Studio, install with the
|
|
153
|
+
project token from your kryptheon.tech dashboard in the environment
|
|
154
|
+
(`KRYPTHEON_PROJECT_TOKEN`, and `KRYPTHEON_REPORT_URL` for where it goes) and
|
|
155
|
+
each night sends that dashboard a summary: the kind of each finding, the table
|
|
156
|
+
it is on, and three counts. Never a row, never a value, never your connection
|
|
157
|
+
string — `report.check.js` reads exactly what would be sent and fails if any of
|
|
158
|
+
those is in it. The token is kept where only the database owner can read it.
|
|
159
|
+
Install again without it and the token is deleted and nothing is sent.
|
|
160
|
+
|
|
152
161
|
**One thing it cannot do.** Racing two requests against each other — the "can
|
|
153
162
|
this exist twice" question — needs two connections at the same instant, and
|
|
154
163
|
nothing living inside a database has them. The nightly report names every
|
|
@@ -158,6 +167,14 @@ properly.
|
|
|
158
167
|
`uninstall` removes the job, the schema, and any extension it had to add, and
|
|
159
168
|
leaves alone anything that was already there.
|
|
160
169
|
|
|
170
|
+
**No terminal at all?** Your dashboard on kryptheon.tech gives you the same
|
|
171
|
+
install as one SQL script: paste it into your Supabase SQL editor and press
|
|
172
|
+
Run. It leaves your database exactly as `npx kryptheon-night install` does —
|
|
173
|
+
`installscript.check.js` installs both ways side by side and compares every
|
|
174
|
+
function, the job, the token and the record uninstall reads — and the matching
|
|
175
|
+
uninstall script takes it back out, whichever way it went in. From code:
|
|
176
|
+
`require('kryptheon-night/installer.js').installScript({ source, reportTo })`.
|
|
177
|
+
|
|
161
178
|
## Three answers, three exit codes
|
|
162
179
|
|
|
163
180
|
| exit | meaning |
|
package/bin/kryptheon-night.js
CHANGED
|
@@ -35,6 +35,32 @@ const installer = require('../installer.js');
|
|
|
35
35
|
const outside = require('../outside.js');
|
|
36
36
|
const schema = require('../schema.js');
|
|
37
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
|
+
|
|
38
64
|
const line = (text) => process.stdout.write(text + '\n');
|
|
39
65
|
const fail = (text) => process.stderr.write(text + '\n');
|
|
40
66
|
|
|
@@ -205,9 +231,10 @@ async function runVerb(command, client, target) {
|
|
|
205
231
|
|
|
206
232
|
if (command === 'install') {
|
|
207
233
|
line(' Installing ...');
|
|
208
|
-
const done = await installer.install(client, { source: target });
|
|
234
|
+
const done = await installer.install(client, { source: target, reportTo: reportToFromEnv() });
|
|
209
235
|
line('');
|
|
210
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.');
|
|
211
238
|
line('');
|
|
212
239
|
if (done.made.extensions.length) {
|
|
213
240
|
line(' I had to add: ' + done.made.extensions.join(', ') + '.');
|
|
@@ -352,7 +379,7 @@ async function offerTheNight(client, target, canAsk) {
|
|
|
352
379
|
return;
|
|
353
380
|
}
|
|
354
381
|
|
|
355
|
-
block(intro.installConsentLines(installer.SCHEMA, target, installer.AT));
|
|
382
|
+
block(intro.installConsentLines(installer.SCHEMA, target, installer.AT, reportToFromEnv()));
|
|
356
383
|
const sure = await intro.askYesNo(' Go ahead? (y/n) ');
|
|
357
384
|
if (!sure) {
|
|
358
385
|
line('');
|
|
@@ -362,9 +389,10 @@ async function offerTheNight(client, target, canAsk) {
|
|
|
362
389
|
}
|
|
363
390
|
|
|
364
391
|
try {
|
|
365
|
-
const done = await installer.install(client, { source: target });
|
|
392
|
+
const done = await installer.install(client, { source: target, reportTo: reportToFromEnv() });
|
|
366
393
|
line('');
|
|
367
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.');
|
|
368
396
|
if (done.made.extensions.length) {
|
|
369
397
|
line(' I added ' + done.made.extensions.join(' and ') + '; uninstall takes them back out.');
|
|
370
398
|
}
|
|
@@ -429,7 +457,7 @@ function wordsFor(target, connection) {
|
|
|
429
457
|
return {
|
|
430
458
|
help: intro.whereToFindIt(),
|
|
431
459
|
consent: intro.consentLines(target),
|
|
432
|
-
installConsent: intro.installConsentLines(installer.SCHEMA, target, installer.AT),
|
|
460
|
+
installConsent: intro.installConsentLines(installer.SCHEMA, target, installer.AT, reportToFromEnv()),
|
|
433
461
|
// pg_cron reads this in the database's own time zone, which is UTC on
|
|
434
462
|
// Supabase; the dashboard turns it into the person's local time.
|
|
435
463
|
nightlyAt: installer.AT,
|
|
@@ -541,7 +569,7 @@ async function main() {
|
|
|
541
569
|
// ends "I do not leave anything behind", which is true of a scan and false
|
|
542
570
|
// of an install - and this is the one screen the product cannot be loose on.
|
|
543
571
|
block(asked.command === 'install'
|
|
544
|
-
? intro.installConsentLines(installer.SCHEMA, target, installer.AT)
|
|
572
|
+
? intro.installConsentLines(installer.SCHEMA, target, installer.AT, reportToFromEnv())
|
|
545
573
|
: intro.consentLines(target));
|
|
546
574
|
const yes = await intro.askYesNo(' Go ahead? (y/n) ');
|
|
547
575
|
if (!yes) {
|
package/engine.sql
CHANGED
|
@@ -3535,3 +3535,71 @@ BEGIN
|
|
|
3535
3535
|
|
|
3536
3536
|
RETURN run_id;
|
|
3537
3537
|
END $$;
|
|
3538
|
+
|
|
3539
|
+
-- --------------------------------------------------------------------------
|
|
3540
|
+
-- The nightly summary, for a person on Pro or Studio.
|
|
3541
|
+
--
|
|
3542
|
+
-- Only when they asked for it at install, by giving a project token: the
|
|
3543
|
+
-- token and the address it goes to are kept in __KN__.reporting, which no
|
|
3544
|
+
-- role but the owner can read. Without that table nothing is ever sent.
|
|
3545
|
+
--
|
|
3546
|
+
-- What is sent is fixed here: the kind of each finding, the table it is on,
|
|
3547
|
+
-- a column when the finding is about one, and three counts. Never a row of
|
|
3548
|
+
-- the customer's data, never a column's values or the list of a table's
|
|
3549
|
+
-- columns, never the connection string, and not even the text of an error,
|
|
3550
|
+
-- which can quote the schema.
|
|
3551
|
+
-- --------------------------------------------------------------------------
|
|
3552
|
+
|
|
3553
|
+
/* The summary of one night, as the body of the report. */
|
|
3554
|
+
CREATE OR REPLACE FUNCTION __KN__.report_body(run_id bigint, engine text)
|
|
3555
|
+
RETURNS jsonb LANGUAGE sql STABLE AS $$
|
|
3556
|
+
SELECT jsonb_build_object(
|
|
3557
|
+
'format', 1,
|
|
3558
|
+
'engine', coalesce(engine, 'kryptheon-night'),
|
|
3559
|
+
'ran_at', r.ran_at,
|
|
3560
|
+
'stopped', r.stopped IS NOT NULL,
|
|
3561
|
+
'attacks_run', r.attacks_run,
|
|
3562
|
+
'not_tested', jsonb_array_length(r.not_checked),
|
|
3563
|
+
'findings', coalesce((
|
|
3564
|
+
SELECT jsonb_agg(jsonb_strip_nulls(jsonb_build_object(
|
|
3565
|
+
'kind', f->>'kind',
|
|
3566
|
+
-- A loop found through a helper names no table; "unknown"
|
|
3567
|
+
-- rather than nothing, so the night is still filed.
|
|
3568
|
+
'table', coalesce(f->>'table', 'unknown'),
|
|
3569
|
+
'column', f->>'column')))
|
|
3570
|
+
FROM jsonb_array_elements(r.findings) f), '[]'::jsonb))
|
|
3571
|
+
FROM __KN__.runs r WHERE r.id = run_id;
|
|
3572
|
+
$$;
|
|
3573
|
+
|
|
3574
|
+
/*
|
|
3575
|
+
* Sends one night's summary, if a project token was given at install. Never
|
|
3576
|
+
* raises: a report that cannot be sent must not cost the night its own record.
|
|
3577
|
+
*/
|
|
3578
|
+
CREATE OR REPLACE FUNCTION __KN__.report(run_id bigint)
|
|
3579
|
+
RETURNS bigint LANGUAGE plpgsql AS $$
|
|
3580
|
+
DECLARE
|
|
3581
|
+
cfg record;
|
|
3582
|
+
sent bigint;
|
|
3583
|
+
BEGIN
|
|
3584
|
+
IF to_regclass('__KN__.reporting') IS NULL THEN RETURN NULL; END IF;
|
|
3585
|
+
EXECUTE 'SELECT endpoint, token, engine FROM __KN__.reporting WHERE id = 1' INTO cfg;
|
|
3586
|
+
IF cfg IS NULL OR cfg.endpoint IS NULL OR cfg.token IS NULL THEN RETURN NULL; END IF;
|
|
3587
|
+
EXECUTE 'SELECT net.http_post(url := $1, body := $2, headers := $3, timeout_milliseconds := 10000)'
|
|
3588
|
+
INTO sent
|
|
3589
|
+
USING cfg.endpoint, __KN__.report_body(run_id, cfg.engine),
|
|
3590
|
+
jsonb_build_object('Content-Type', 'application/json', 'Authorization', 'Bearer ' || cfg.token);
|
|
3591
|
+
RETURN sent;
|
|
3592
|
+
EXCEPTION WHEN OTHERS THEN
|
|
3593
|
+
RETURN NULL;
|
|
3594
|
+
END $$;
|
|
3595
|
+
|
|
3596
|
+
/* What the scheduled job runs when a summary was asked for. */
|
|
3597
|
+
CREATE OR REPLACE FUNCTION __KN__.nightly_and_report(source text)
|
|
3598
|
+
RETURNS bigint LANGUAGE plpgsql AS $$
|
|
3599
|
+
DECLARE
|
|
3600
|
+
run_id bigint;
|
|
3601
|
+
BEGIN
|
|
3602
|
+
run_id := __KN__.nightly(source);
|
|
3603
|
+
PERFORM __KN__.report(run_id);
|
|
3604
|
+
RETURN run_id;
|
|
3605
|
+
END $$;
|
package/installer.js
CHANGED
|
@@ -87,6 +87,10 @@ async function install(client, options) {
|
|
|
87
87
|
const at = opts.at || AT;
|
|
88
88
|
const made = { extensions: [], schema: false, jobid: null };
|
|
89
89
|
|
|
90
|
+
// A bad project token is refused here, before anything is made, for the
|
|
91
|
+
// same reason as the extensions below.
|
|
92
|
+
if (opts.reportTo) checkReportTo(opts.reportTo);
|
|
93
|
+
|
|
90
94
|
// Both asked about before either is made. pg_net used to be created first
|
|
91
95
|
// and pg_cron checked after, so a database offering one and not the other
|
|
92
96
|
// kept a pg_net nobody recorded - and uninstall only removes what the
|
|
@@ -132,12 +136,15 @@ async function install(client, options) {
|
|
|
132
136
|
'CONSTRAINT only_one CHECK (id = 1))',
|
|
133
137
|
);
|
|
134
138
|
|
|
139
|
+
// The nightly summary, only if a project token was given.
|
|
140
|
+
const reporting = await configureReporting(client, schema, opts.reportTo || null);
|
|
141
|
+
|
|
135
142
|
// Replaced rather than added to. cron.schedule with a name that already
|
|
136
143
|
// exists updates it, but an install that ran twice under two names would
|
|
137
144
|
// attack the same app twice a night for ever.
|
|
138
145
|
const { rows } = await client.query(
|
|
139
146
|
'SELECT cron.schedule($1, $2, $3) AS jobid',
|
|
140
|
-
['kryptheon_nightly', at,
|
|
147
|
+
['kryptheon_nightly', at, jobCommand(schema, source, reporting)],
|
|
141
148
|
);
|
|
142
149
|
made.jobid = rows[0].jobid;
|
|
143
150
|
|
|
@@ -150,7 +157,288 @@ async function install(client, options) {
|
|
|
150
157
|
[source, made.extensions, made.schema, made.jobid],
|
|
151
158
|
);
|
|
152
159
|
|
|
153
|
-
return { schema: schema, source: source, at: at, jobid: made.jobid, made: made };
|
|
160
|
+
return { schema: schema, source: source, at: at, jobid: made.jobid, made: made, reporting: reporting };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** What the scheduled job runs: the night, and the summary only if one was asked for. */
|
|
164
|
+
function jobCommand(schema, source, reporting) {
|
|
165
|
+
return 'SELECT ' + quote(schema) + '.' + (reporting ? 'nightly_and_report' : 'nightly') + '(' + literal(source) + ')';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// A project token from kryptheon.tech, and the one address it may be sent to.
|
|
169
|
+
const TOKEN = /^kp_[A-Za-z0-9_-]{20,100}$/;
|
|
170
|
+
const ENDPOINT = /^https:\/\/[a-z0-9-]+(\.[a-z0-9-]+)+(:443)?\/functions\/v1\/ingest$/i;
|
|
171
|
+
|
|
172
|
+
/** Throws, in words a person can act on, unless this is a token and an address it may go to. */
|
|
173
|
+
function checkReportTo(reportTo) {
|
|
174
|
+
if (!TOKEN.test(String(reportTo.token || ''))) {
|
|
175
|
+
throw new Error('that is not a project token - copy it again from your dashboard on kryptheon.tech');
|
|
176
|
+
}
|
|
177
|
+
if (!ENDPOINT.test(String(reportTo.endpoint || ''))) {
|
|
178
|
+
throw new Error('the report address has to be an https address ending in /functions/v1/ingest');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Keeps the project token where the nightly job can read it and nobody else
|
|
184
|
+
* can, or takes it away when none is given - so an install without one sends
|
|
185
|
+
* nothing, even if an earlier install was given one.
|
|
186
|
+
*
|
|
187
|
+
* In a table of the engine's own schema, readable by its owner only: anon and
|
|
188
|
+
* authenticated - the roles a Supabase API call runs as - get nothing, so the
|
|
189
|
+
* token never reaches the app's API even if the schema were exposed.
|
|
190
|
+
*/
|
|
191
|
+
async function configureReporting(client, schema, reportTo) {
|
|
192
|
+
const table = quote(schema) + '.reporting';
|
|
193
|
+
if (!reportTo) {
|
|
194
|
+
await client.query('DROP TABLE IF EXISTS ' + table);
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
checkReportTo(reportTo);
|
|
198
|
+
await client.query(
|
|
199
|
+
'CREATE TABLE IF NOT EXISTS ' + table + ' (' +
|
|
200
|
+
'id integer PRIMARY KEY DEFAULT 1, endpoint text NOT NULL, token text NOT NULL, engine text, ' +
|
|
201
|
+
'CONSTRAINT only_one CHECK (id = 1))',
|
|
202
|
+
);
|
|
203
|
+
await client.query('REVOKE ALL ON ' + table + ' FROM PUBLIC');
|
|
204
|
+
for (const role of ['anon', 'authenticated']) {
|
|
205
|
+
const { rows } = await client.query('SELECT 1 FROM pg_roles WHERE rolname = $1', [role]);
|
|
206
|
+
if (rows.length) await client.query('REVOKE ALL ON ' + table + ' FROM ' + role);
|
|
207
|
+
}
|
|
208
|
+
await client.query(
|
|
209
|
+
'INSERT INTO ' + table + ' (id, endpoint, token, engine) VALUES (1, $1, $2, $3) ' +
|
|
210
|
+
'ON CONFLICT (id) DO UPDATE SET endpoint = EXCLUDED.endpoint, token = EXCLUDED.token, engine = EXCLUDED.engine',
|
|
211
|
+
[reportTo.endpoint, reportTo.token, reportTo.engine || null],
|
|
212
|
+
);
|
|
213
|
+
return { endpoint: reportTo.endpoint };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/*
|
|
217
|
+
* The same install, as one SQL script a person pastes into their Supabase SQL
|
|
218
|
+
* editor and runs - for somebody who never opens a terminal. It has to leave
|
|
219
|
+
* the database exactly as install() does (installscript.check.js holds the
|
|
220
|
+
* two side by side), so it does the same things in the same order and writes
|
|
221
|
+
* down the same record for uninstall to read.
|
|
222
|
+
*
|
|
223
|
+
* Every value a person supplies - the schema their app is in, the project
|
|
224
|
+
* token, where the summary goes - appears once, in the settings at the top,
|
|
225
|
+
* as one plain SQL string. The rest of the script reads them from there. That
|
|
226
|
+
* is what lets kryptheon.tech fill the script in a browser without knowing
|
|
227
|
+
* any SQL: it replaces three markers, each inside a single string.
|
|
228
|
+
*/
|
|
229
|
+
const SLOTS = {
|
|
230
|
+
source: 'KN_SLOT_SOURCE',
|
|
231
|
+
token: 'KN_SLOT_TOKEN',
|
|
232
|
+
endpoint: 'KN_SLOT_ENDPOINT',
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
function engineName() {
|
|
236
|
+
try {
|
|
237
|
+
const manifest = JSON.parse(require('fs').readFileSync(require('path').join(__dirname, 'package.json'), 'utf8'));
|
|
238
|
+
return 'kryptheon-night ' + manifest.version;
|
|
239
|
+
} catch (err) {
|
|
240
|
+
return 'kryptheon-night';
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The script with its three markers in place, for a page to fill. Without a
|
|
246
|
+
* token the slot is filled with nothing, and the script installs the plain
|
|
247
|
+
* night that sends nothing anywhere.
|
|
248
|
+
*/
|
|
249
|
+
function installTemplate(options) {
|
|
250
|
+
const opts = options || {};
|
|
251
|
+
const schema = opts.schema || SCHEMA;
|
|
252
|
+
const at = opts.at || AT;
|
|
253
|
+
const engine = opts.engine || engineName();
|
|
254
|
+
const q = quote(schema);
|
|
255
|
+
const lit = (v) => literal(v);
|
|
256
|
+
return [
|
|
257
|
+
'-- Kryptheon: the nightly check, set up from inside your database.',
|
|
258
|
+
'-- ' + engine + '. Paste all of this into the SQL editor of the project',
|
|
259
|
+
'-- you want watched, and press Run. If Supabase warns about a destructive',
|
|
260
|
+
'-- operation: it means the "DROP ... IF EXISTS" lines below, which only ever',
|
|
261
|
+
'-- touch what Kryptheon itself made.',
|
|
262
|
+
'--',
|
|
263
|
+
'-- What it makes: a schema called ' + schema + ' with the checking functions in it,',
|
|
264
|
+
'-- pg_cron and pg_net if they are not here yet, and one job, kryptheon_nightly,',
|
|
265
|
+
'-- at ' + at + ' (UTC on Supabase). It never reads a row of your data.',
|
|
266
|
+
'--',
|
|
267
|
+
'-- To take it all away again: the uninstall script on kryptheon.tech, or',
|
|
268
|
+
'-- npx kryptheon-night uninstall',
|
|
269
|
+
'',
|
|
270
|
+
'-- Your settings. Only these three lines are yours; the rest is the same for everybody.',
|
|
271
|
+
'DROP TABLE IF EXISTS pg_temp.kn_install;',
|
|
272
|
+
'CREATE TEMP TABLE kn_install (source text NOT NULL, token text, endpoint text, made_extensions text[] NOT NULL DEFAULT \'{}\', made_schema boolean);',
|
|
273
|
+
'INSERT INTO kn_install (source, token, endpoint) VALUES (',
|
|
274
|
+
" '" + SLOTS.source + "', -- the schema your app's tables are in",
|
|
275
|
+
" '" + SLOTS.token + "', -- your project token; empty for no nightly summary",
|
|
276
|
+
" '" + SLOTS.endpoint + "' -- where the summary goes",
|
|
277
|
+
');',
|
|
278
|
+
"UPDATE kn_install SET token = nullif(token, ''), endpoint = nullif(endpoint, '');",
|
|
279
|
+
'',
|
|
280
|
+
'-- Refused before anything is made: a token that is not one, an address that',
|
|
281
|
+
'-- is not Kryptheon\'s, or a database without the two extensions.',
|
|
282
|
+
'DO $kn$',
|
|
283
|
+
'DECLARE s record;',
|
|
284
|
+
'BEGIN',
|
|
285
|
+
' SELECT * INTO s FROM kn_install;',
|
|
286
|
+
" IF s.source = '' THEN RAISE EXCEPTION 'Kryptheon: name the schema your app is in (it is \"public\" for almost everyone)'; END IF;",
|
|
287
|
+
' IF s.token IS NOT NULL OR s.endpoint IS NOT NULL THEN',
|
|
288
|
+
" IF s.token IS NULL OR s.token !~ '^kp_[A-Za-z0-9_-]{20,100}$' THEN",
|
|
289
|
+
" RAISE EXCEPTION 'Kryptheon: that is not a project token - copy it again from your dashboard on kryptheon.tech';",
|
|
290
|
+
' END IF;',
|
|
291
|
+
" IF s.endpoint IS NULL OR s.endpoint !~* '^https://[a-z0-9-]+(\\.[a-z0-9-]+)+(:443)?/functions/v1/ingest$' THEN",
|
|
292
|
+
" RAISE EXCEPTION 'Kryptheon: the report address has to be an https address ending in /functions/v1/ingest';",
|
|
293
|
+
' END IF;',
|
|
294
|
+
' END IF;',
|
|
295
|
+
" IF NOT EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'pg_net') THEN",
|
|
296
|
+
" RAISE EXCEPTION 'Kryptheon: this database does not offer pg_net, so the nightly run cannot be installed. Supabase has it; a plain Postgres may not.';",
|
|
297
|
+
' END IF;',
|
|
298
|
+
" IF NOT EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'pg_cron') THEN",
|
|
299
|
+
" RAISE EXCEPTION 'Kryptheon: this database does not offer pg_cron, so the nightly run cannot be installed. Supabase has it; a plain Postgres may not.';",
|
|
300
|
+
' END IF;',
|
|
301
|
+
' -- pg_net first, and told where to go: left to itself it lands in your own public schema.',
|
|
302
|
+
" IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_net') THEN",
|
|
303
|
+
" IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'extensions') THEN",
|
|
304
|
+
' CREATE EXTENSION pg_net WITH SCHEMA extensions;',
|
|
305
|
+
' ELSE',
|
|
306
|
+
' CREATE EXTENSION pg_net;',
|
|
307
|
+
' END IF;',
|
|
308
|
+
" UPDATE kn_install SET made_extensions = array_append(made_extensions, 'pg_net');",
|
|
309
|
+
' END IF;',
|
|
310
|
+
" IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron') THEN",
|
|
311
|
+
' CREATE EXTENSION pg_cron;',
|
|
312
|
+
" UPDATE kn_install SET made_extensions = array_append(made_extensions, 'pg_cron');",
|
|
313
|
+
' END IF;',
|
|
314
|
+
' UPDATE kn_install SET made_schema = NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = ' + lit(schema) + ');',
|
|
315
|
+
'END $kn$;',
|
|
316
|
+
'',
|
|
317
|
+
'-- The checking functions.',
|
|
318
|
+
sqlengine.engineFor(schema),
|
|
319
|
+
'',
|
|
320
|
+
'-- What this install made, where uninstall will look for it.',
|
|
321
|
+
'CREATE TABLE IF NOT EXISTS ' + q + '.installed (' +
|
|
322
|
+
'id integer PRIMARY KEY DEFAULT 1, ' +
|
|
323
|
+
'installed_at timestamptz NOT NULL DEFAULT now(), ' +
|
|
324
|
+
'source text NOT NULL, ' +
|
|
325
|
+
'made_extensions text[] NOT NULL DEFAULT \'{}\', ' +
|
|
326
|
+
'made_schema boolean NOT NULL DEFAULT false, ' +
|
|
327
|
+
'jobid bigint, ' +
|
|
328
|
+
'CONSTRAINT only_one CHECK (id = 1));',
|
|
329
|
+
'',
|
|
330
|
+
'-- The project token, kept where only the database owner can read it - never',
|
|
331
|
+
'-- anon or authenticated, the roles your app\'s API runs as. With no token,',
|
|
332
|
+
'-- an earlier one is taken away and nothing is ever sent.',
|
|
333
|
+
'DO $kn$',
|
|
334
|
+
'DECLARE s record; r text;',
|
|
335
|
+
'BEGIN',
|
|
336
|
+
' SELECT * INTO s FROM kn_install;',
|
|
337
|
+
' IF s.token IS NULL THEN',
|
|
338
|
+
' DROP TABLE IF EXISTS ' + q + '.reporting;',
|
|
339
|
+
' RETURN;',
|
|
340
|
+
' END IF;',
|
|
341
|
+
' CREATE TABLE IF NOT EXISTS ' + q + '.reporting (' +
|
|
342
|
+
'id integer PRIMARY KEY DEFAULT 1, endpoint text NOT NULL, token text NOT NULL, engine text, ' +
|
|
343
|
+
'CONSTRAINT only_one CHECK (id = 1));',
|
|
344
|
+
' REVOKE ALL ON ' + q + '.reporting FROM PUBLIC;',
|
|
345
|
+
" FOREACH r IN ARRAY ARRAY['anon', 'authenticated'] LOOP",
|
|
346
|
+
" IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = r) THEN EXECUTE format('REVOKE ALL ON %s.reporting FROM %I', " + lit(q) + ', r); END IF;',
|
|
347
|
+
' END LOOP;',
|
|
348
|
+
' INSERT INTO ' + q + '.reporting (id, endpoint, token, engine) VALUES (1, s.endpoint, s.token, ' + lit(engine) + ')',
|
|
349
|
+
' ON CONFLICT (id) DO UPDATE SET endpoint = EXCLUDED.endpoint, token = EXCLUDED.token, engine = EXCLUDED.engine;',
|
|
350
|
+
'END $kn$;',
|
|
351
|
+
'',
|
|
352
|
+
'-- The nightly job, replaced rather than added to, and the record of it all.',
|
|
353
|
+
'DO $kn$',
|
|
354
|
+
'DECLARE s record; job bigint;',
|
|
355
|
+
'BEGIN',
|
|
356
|
+
' SELECT * INTO s FROM kn_install;',
|
|
357
|
+
" job := cron.schedule('kryptheon_nightly', " + lit(at) + ',',
|
|
358
|
+
" 'SELECT ' || " + lit(q) + " || '.' || CASE WHEN s.token IS NULL THEN 'nightly' ELSE 'nightly_and_report' END ||",
|
|
359
|
+
" '(''' || replace(s.source, '''', '''''') || ''')');",
|
|
360
|
+
' INSERT INTO ' + q + '.installed (id, source, made_extensions, made_schema, jobid)',
|
|
361
|
+
' VALUES (1, s.source, s.made_extensions, s.made_schema, job)',
|
|
362
|
+
' ON CONFLICT (id) DO UPDATE SET source = EXCLUDED.source, jobid = EXCLUDED.jobid,',
|
|
363
|
+
' made_extensions = ' + q + '.installed.made_extensions || EXCLUDED.made_extensions, installed_at = now();',
|
|
364
|
+
'END $kn$;',
|
|
365
|
+
'',
|
|
366
|
+
"SELECT 'Installed. Kryptheon will check \"' || source || '\" every night at " + at + " (UTC)' ||",
|
|
367
|
+
" CASE WHEN token IS NULL THEN ', and sends nothing anywhere.' ELSE ', and sends your Kryptheon dashboard a summary - never a row of data.' END AS kryptheon",
|
|
368
|
+
' FROM kn_install;',
|
|
369
|
+
'DROP TABLE kn_install;',
|
|
370
|
+
'',
|
|
371
|
+
].join('\n');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* uninstall() as one pasted script: the job first, then the schema, then only
|
|
376
|
+
* the extensions the install wrote down as its own. Reads the same record the
|
|
377
|
+
* CLI does, so it takes back an install made either way.
|
|
378
|
+
*/
|
|
379
|
+
function uninstallScript(options) {
|
|
380
|
+
const schema = (options || {}).schema || SCHEMA;
|
|
381
|
+
const q = quote(schema);
|
|
382
|
+
return [
|
|
383
|
+
'-- Kryptheon: take the nightly check away again. Paste all of this into the SQL',
|
|
384
|
+
'-- editor of the project it watches, and press Run. It removes the job, the',
|
|
385
|
+
'-- ' + schema + ' schema, and any extension Kryptheon itself added - and leaves',
|
|
386
|
+
'-- alone anything that was already here.',
|
|
387
|
+
'DO $kn$',
|
|
388
|
+
'DECLARE job bigint; made_schema boolean; made text[]; e text;',
|
|
389
|
+
'BEGIN',
|
|
390
|
+
' IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = ' + literal(schema) + ') THEN',
|
|
391
|
+
" RAISE NOTICE 'Kryptheon is not installed here. Nothing was changed.';",
|
|
392
|
+
' RETURN;',
|
|
393
|
+
' END IF;',
|
|
394
|
+
' BEGIN',
|
|
395
|
+
' EXECUTE ' + literal('SELECT jobid, made_schema, made_extensions FROM ' + q + '.installed WHERE id = 1') + ' INTO job, made_schema, made;',
|
|
396
|
+
' EXCEPTION WHEN OTHERS THEN',
|
|
397
|
+
' job := NULL; made_schema := NULL; made := NULL; -- no record: the schema goes, nothing else',
|
|
398
|
+
' END;',
|
|
399
|
+
' IF job IS NOT NULL THEN',
|
|
400
|
+
' BEGIN',
|
|
401
|
+
' PERFORM cron.unschedule(job);',
|
|
402
|
+
' EXCEPTION WHEN OTHERS THEN NULL;',
|
|
403
|
+
' END;',
|
|
404
|
+
" IF to_regclass('cron.job') IS NOT NULL AND EXISTS (SELECT 1 FROM cron.job j WHERE j.jobid = job) THEN",
|
|
405
|
+
" RAISE EXCEPTION 'Kryptheon: the nightly job % is still scheduled, so nothing else was removed', job;",
|
|
406
|
+
' END IF;',
|
|
407
|
+
' END IF;',
|
|
408
|
+
' DROP SCHEMA IF EXISTS ' + q + ' CASCADE;',
|
|
409
|
+
" FOREACH e IN ARRAY coalesce(made, '{}') LOOP",
|
|
410
|
+
' BEGIN',
|
|
411
|
+
" EXECUTE 'DROP EXTENSION IF EXISTS ' || e;",
|
|
412
|
+
' EXCEPTION WHEN OTHERS THEN NULL;',
|
|
413
|
+
' END;',
|
|
414
|
+
' END LOOP;',
|
|
415
|
+
'END $kn$;',
|
|
416
|
+
'',
|
|
417
|
+
"SELECT 'Kryptheon is gone from this database: no job, no schema, and only the extensions it added were removed.' AS kryptheon;",
|
|
418
|
+
'',
|
|
419
|
+
].join('\n');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** A value, ready to stand where a marker stood inside a SQL string. */
|
|
423
|
+
function fillSlot(value) {
|
|
424
|
+
return String(value == null ? '' : value).split("'").join("''");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The script, filled in. Refuses a bad token the way install() does, before
|
|
429
|
+
* there is anything to paste.
|
|
430
|
+
*/
|
|
431
|
+
function installScript(options) {
|
|
432
|
+
const opts = options || {};
|
|
433
|
+
if (opts.reportTo) checkReportTo(opts.reportTo);
|
|
434
|
+
const report = opts.reportTo || {};
|
|
435
|
+
const values = { source: opts.source || 'public', token: report.token, endpoint: report.endpoint };
|
|
436
|
+
let sql = installTemplate({ schema: opts.schema, at: opts.at, engine: report.engine });
|
|
437
|
+
for (const [slot, marker] of Object.entries(SLOTS)) {
|
|
438
|
+
if (sql.split(marker).length !== 2) throw new Error('the install script carries ' + marker + ' more or less than once');
|
|
439
|
+
sql = sql.split(marker).join(fillSlot(values[slot]));
|
|
440
|
+
}
|
|
441
|
+
return sql;
|
|
154
442
|
}
|
|
155
443
|
|
|
156
444
|
/** A string, quoted the way Postgres wants it inside another statement. */
|
|
@@ -252,4 +540,10 @@ module.exports = {
|
|
|
252
540
|
uninstall: uninstall,
|
|
253
541
|
status: status,
|
|
254
542
|
extensionState: extensionState,
|
|
543
|
+
configureReporting: configureReporting,
|
|
544
|
+
jobCommand: jobCommand,
|
|
545
|
+
installTemplate: installTemplate,
|
|
546
|
+
installScript: installScript,
|
|
547
|
+
uninstallScript: uninstallScript,
|
|
548
|
+
SLOTS: SLOTS,
|
|
255
549
|
};
|
package/intro.js
CHANGED
|
@@ -123,7 +123,21 @@ function consentLines(target) {
|
|
|
123
123
|
* recognise. And the way out is on the screen where they say yes, not in a
|
|
124
124
|
* README they will not read again.
|
|
125
125
|
*/
|
|
126
|
-
function installConsentLines(schema, source, at) {
|
|
126
|
+
function installConsentLines(schema, source, at, reporting) {
|
|
127
|
+
// Said as it is: with a project token the night does send something, so
|
|
128
|
+
// "sends nothing anywhere" would be a lie on exactly the screen that asks.
|
|
129
|
+
const sends = reporting
|
|
130
|
+
? [
|
|
131
|
+
' - It still never reads your real data. Once a night it sends your',
|
|
132
|
+
' Kryptheon dashboard a summary of what it found: the kind of each',
|
|
133
|
+
' finding and the table it is on. Never a row, never a value, never',
|
|
134
|
+
' your connection string. That is what your morning report and',
|
|
135
|
+
' alerts are made from.',
|
|
136
|
+
]
|
|
137
|
+
: [
|
|
138
|
+
' - It still never reads your real data, and still sends nothing anywhere.',
|
|
139
|
+
' Nothing leaves your database, because the checking happens inside it.',
|
|
140
|
+
];
|
|
127
141
|
return [
|
|
128
142
|
'This one stays. Everything else this tool does is temporary; this is not.',
|
|
129
143
|
'',
|
|
@@ -143,8 +157,7 @@ function installConsentLines(schema, source, at) {
|
|
|
143
157
|
' tables into a temporary space, put two made-up people in it, attack',
|
|
144
158
|
' that, write down what got through, and delete the copy.',
|
|
145
159
|
'',
|
|
146
|
-
|
|
147
|
-
' Nothing leaves your database, because the checking happens inside it.',
|
|
160
|
+
...sends,
|
|
148
161
|
'',
|
|
149
162
|
'One thing it cannot do:',
|
|
150
163
|
'',
|
|
@@ -161,11 +174,20 @@ function installConsentLines(schema, source, at) {
|
|
|
161
174
|
];
|
|
162
175
|
}
|
|
163
176
|
|
|
164
|
-
/**
|
|
165
|
-
|
|
177
|
+
/**
|
|
178
|
+
* Asks a question and hands back what was typed - or null when there is no
|
|
179
|
+
* more input to read, so a closed keyboard is an answer and not a wait forever.
|
|
180
|
+
*/
|
|
181
|
+
function ask(question, io) {
|
|
182
|
+
const streams = io || { input: process.stdin, output: process.stdout };
|
|
166
183
|
return new Promise((resolve) => {
|
|
167
|
-
const rl = readline.createInterface({ input:
|
|
184
|
+
const rl = readline.createInterface({ input: streams.input, output: streams.output });
|
|
185
|
+
let answered = false;
|
|
186
|
+
rl.on('close', () => {
|
|
187
|
+
if (!answered) resolve(null);
|
|
188
|
+
});
|
|
168
189
|
rl.question(question, (answer) => {
|
|
190
|
+
answered = true;
|
|
169
191
|
rl.close();
|
|
170
192
|
resolve(String(answer || '').trim());
|
|
171
193
|
});
|
|
@@ -222,9 +244,24 @@ function isYes(answer) {
|
|
|
222
244
|
return said === 'y' || said === 'yes';
|
|
223
245
|
}
|
|
224
246
|
|
|
225
|
-
/**
|
|
226
|
-
|
|
227
|
-
|
|
247
|
+
/**
|
|
248
|
+
* Yes or no, where anything that is not a clear yes is a no - except an empty
|
|
249
|
+
* line, which is asked again.
|
|
250
|
+
*
|
|
251
|
+
* On Windows a pasted connection string can carry its own line ending: the
|
|
252
|
+
* hidden prompt takes it and moves on, and the Enter the person then presses
|
|
253
|
+
* to "send" the paste lands here, on a question they have not read yet. Taken
|
|
254
|
+
* as a no, it stopped an install nobody had said no to. An empty line is
|
|
255
|
+
* still never a yes; it is only not an answer.
|
|
256
|
+
*/
|
|
257
|
+
async function askYesNo(question, io) {
|
|
258
|
+
const streams = io || { input: process.stdin, output: process.stdout };
|
|
259
|
+
for (;;) {
|
|
260
|
+
const answer = await ask(question, streams);
|
|
261
|
+
if (answer === null) return false; // nothing more can be typed
|
|
262
|
+
if (answer !== '') return isYes(answer);
|
|
263
|
+
streams.output.write(' Type y and press Enter to go ahead, or n to stop.\n');
|
|
264
|
+
}
|
|
228
265
|
}
|
|
229
266
|
|
|
230
267
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kryptheon-night",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.31",
|
|
4
4
|
"description": "Attacks a copy of your Supabase database and tells you in plain English what got in.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -31,13 +31,14 @@
|
|
|
31
31
|
"node": ">=18.0.0"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
-
"check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node recursion.check.js && node guarded.check.js && node nightly.check.js && node words.check.js && node helpers.check.js && node deeploop.check.js && node teams.check.js && node roles.check.js && node buckets.check.js && node cli.check.js && node loop.check.js",
|
|
34
|
+
"check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node recursion.check.js && node guarded.check.js && node nightly.check.js && node words.check.js && node helpers.check.js && node deeploop.check.js && node teams.check.js && node roles.check.js && node buckets.check.js && node cli.check.js && node loop.check.js && node report.check.js && node installscript.check.js",
|
|
35
35
|
"check:dry": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node recheck.check.js && node outside.check.js",
|
|
36
36
|
"check:package": "node package.check.js",
|
|
37
37
|
"check:store": "node store.check.js",
|
|
38
38
|
"check:trouble": "node trouble.check.js",
|
|
39
39
|
"check:intro": "node intro.check.js",
|
|
40
40
|
"check:report": "node finding.check.js",
|
|
41
|
+
"check:nightly-report": "node report.check.js",
|
|
41
42
|
"check:outside": "node outside.check.js",
|
|
42
43
|
"check:recheck": "node recheck.check.js",
|
|
43
44
|
"check:copy": "node schema.check.js",
|
|
@@ -73,7 +74,8 @@
|
|
|
73
74
|
"check:engines": "node engines.check.js",
|
|
74
75
|
"check:installer": "node installer.check.js",
|
|
75
76
|
"check:roles": "node roles.check.js",
|
|
76
|
-
"check:buckets": "node buckets.check.js"
|
|
77
|
+
"check:buckets": "node buckets.check.js",
|
|
78
|
+
"check:installscript": "node installscript.check.js"
|
|
77
79
|
},
|
|
78
80
|
"keywords": [
|
|
79
81
|
"supabase",
|