kryptheon-night 0.1.29 → 0.1.30

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
@@ -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
@@ -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, 'SELECT ' + quote(schema) + '.nightly(' + literal(source) + ')'],
147
+ ['kryptheon_nightly', at, jobCommand(schema, source, reporting)],
141
148
  );
142
149
  made.jobid = rows[0].jobid;
143
150
 
@@ -150,7 +157,60 @@ 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 };
154
214
  }
155
215
 
156
216
  /** A string, quoted the way Postgres wants it inside another statement. */
@@ -252,4 +312,6 @@ module.exports = {
252
312
  uninstall: uninstall,
253
313
  status: status,
254
314
  extensionState: extensionState,
315
+ configureReporting: configureReporting,
316
+ jobCommand: jobCommand,
255
317
  };
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
- ' - It still never reads your real data, and still sends nothing anywhere.',
147
- ' Nothing leaves your database, because the checking happens inside it.',
160
+ ...sends,
148
161
  '',
149
162
  'One thing it cannot do:',
150
163
  '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon-night",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
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",
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",