kryptheon-night 0.1.30 → 0.1.32

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/finding.js CHANGED
@@ -358,6 +358,10 @@ function headlineFor(finding) {
358
358
  if (finding.kind === 'privileged') {
359
359
  return 'Anyone on the internet can call your ' + finding.fn + ' function, and it runs with full rights.';
360
360
  }
361
+ if (finding.kind === 'role' && finding.takeover) {
362
+ return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can take another member\'s place in your ' +
363
+ finding.table + ' table - and with it their role.';
364
+ }
361
365
  if (finding.kind === 'role') {
362
366
  const order = ['delete', 'change', 'add'];
363
367
  const does = order.filter((what) => (finding.can || []).includes(what)).map((what) => WRITE_WORDS[what]);
@@ -368,6 +372,9 @@ function headlineFor(finding) {
368
372
  return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can read your ' + finding.table +
369
373
  ' table, which holds ' + listOf(finding.secrets || []) + '.';
370
374
  }
375
+ if (finding.kind === 'bucket' && finding.noRule) {
376
+ return 'Your ' + finding.table + ' storage bucket is public, and its name says its files are not for everyone.';
377
+ }
371
378
  if (finding.kind === 'bucket') {
372
379
  return 'Your ' + finding.table + ' storage bucket is public, but a rule says only some people may read it.';
373
380
  }
@@ -377,6 +384,7 @@ function headlineFor(finding) {
377
384
  // The table name goes in front of the sentence rather than inside it. Put
378
385
  // inside, a table called `customers` produced "One customer can read another
379
386
  // customer's customers", which is the kind of line that loses a reader.
387
+ if (finding.tenant) return 'Your ' + finding.table + ' table lets a signed-in user read another organization\'s rows.';
380
388
  return 'Your ' + finding.table + ' table lets one customer read another one\'s rows.';
381
389
  }
382
390
 
@@ -454,6 +462,20 @@ function bodyFor(finding, contents) {
454
462
  'kept, and your live app was never touched.';
455
463
  }
456
464
 
465
+ if (finding.kind === 'role' && finding.takeover) {
466
+ // Not a matter of taste like a viewer who may edit: taking somebody
467
+ // else's membership is never what a role is for.
468
+ const place = oneOf(finding.parent);
469
+ return (
470
+ 'I put one of my fake people into the other one\'s ' + place + ' as ' + aRole(finding.who) +
471
+ ' in ' + finding.via + '.' + finding.roleColumn + ' and, signed in as them, changed ' +
472
+ finding.via + '.' + finding.personColumn + ' on the other person\'s own membership row to their own id - so ' +
473
+ 'they held that member\'s place and role, on a copy of your app. Before joining, they could not. It was ' +
474
+ 'undone straight away. A member taking over another member\'s row is never something a role should be able ' +
475
+ 'to do, even where members may edit some of a membership.'
476
+ );
477
+ }
478
+
457
479
  if (finding.kind === 'role') {
458
480
  // Said as what was done, and then whose decision it is: a viewer who may
459
481
  // edit is a hole in one app and the design of another.
@@ -494,6 +516,15 @@ function bodyFor(finding, contents) {
494
516
  );
495
517
  }
496
518
 
519
+ if (finding.kind === 'bucket' && finding.noRule) {
520
+ return (
521
+ 'In Supabase Storage the bucket "' + finding.bucket + '" is set to public, so anyone with a ' +
522
+ 'file\'s link can download it without logging in - the rules on storage.objects are not asked. ' +
523
+ 'No rule says who may read it, but its name says what it holds, and that does not sound like ' +
524
+ 'something for everyone. I only read the bucket\'s setting and its rules; I did not open or list ' +
525
+ 'any file. Whether it should be public is yours to say.'
526
+ );
527
+ }
497
528
  if (finding.kind === 'bucket') {
498
529
  // Said as what was read, never as a file taken: no file was opened.
499
530
  return (
@@ -520,6 +551,11 @@ function bodyFor(finding, contents) {
520
551
  : ', reaching whatever it reads past the rules on those tables.') +
521
552
  ' I did not call it: whether an open function like this is intended is ' +
522
553
  'something only you can confirm.' +
554
+ (finding.nullSlips
555
+ ? ' Its first lines do check the caller\'s role - but with NOT IN or <>, and somebody with no role ' +
556
+ 'there gets NULL back, and NULL NOT IN (...) is not true: the IF never raises, so a signed-in ' +
557
+ 'stranger gets through as if they had passed.'
558
+ : '') +
523
559
  (finding.hasFixedSearchPath ? ''
524
560
  : ' It also does not pin its search_path, so a caller can point the names ' +
525
561
  'inside it at their own objects.')
@@ -558,6 +594,13 @@ function bodyFor(finding, contents) {
558
594
  (holds ? ', including ' + holds : '') + '.'
559
595
  );
560
596
  }
597
+ if (finding.tenant) {
598
+ return (
599
+ 'Signed in as someone in a different organization, I asked for another organization\'s rows and got ' +
600
+ finding.readable + ' of them back' + (holds ? ', including ' + holds : '') +
601
+ '. Anyone with an account can read every organization\'s rows.'
602
+ );
603
+ }
561
604
  return (
562
605
  'Signed in as one customer, I asked for another customer\'s rows and got ' +
563
606
  finding.readable + ' of them back' + (holds ? ', including ' + holds : '') +
@@ -629,14 +672,17 @@ function serverOnlyLines(finding) {
629
672
  // column saying whose row it is. The assistant applying it had to correct
630
673
  // the prompt; a less careful one would have locked the table away from the
631
674
  // members who use it.
632
- const team = finding.authTied !== false && (finding.columns || []).find((c) => TENANT_COLUMN.test(String(c)));
675
+ // A row an organization owns names its organization even when the column is
676
+ // not called organization_id: the attack found which key it is.
677
+ const team = finding.authTied !== false &&
678
+ (finding.tenant ? finding.owner : (finding.columns || []).find((c) => TENANT_COLUMN.test(String(c))));
633
679
  const maker = finding.owner || finding.ownedBy;
634
680
  // A team's row that also says who made it - created_by beside workspace_id.
635
681
  // Found on a benchmark: the prompt said to compare created_by to auth.uid()
636
682
  // for reading, which hides every teammate's rows and breaks the shared
637
683
  // view the table exists for. Who made a row decides who may write it
638
684
  // under their name, not who may see it.
639
- if (team && finding.owned !== false && maker) {
685
+ if (team && !finding.tenant && finding.owned !== false && maker) {
640
686
  return [
641
687
  'Rows in "' + table + '" belong to a team ("' + team + '"), and "' + maker + '" only records who ' +
642
688
  'made each one. So reading should be decided by team membership: let a signed-in user read a ' +
@@ -654,7 +700,7 @@ function serverOnlyLines(finding) {
654
700
  '',
655
701
  ];
656
702
  }
657
- const tenant = finding.owned === false && team;
703
+ const tenant = (finding.tenant || finding.owned === false) && team;
658
704
  if (tenant) {
659
705
  return [
660
706
  'Rows in "' + table + '" belong to an organization ("' + tenant + '"), not to one person. So the ' +
@@ -785,6 +831,23 @@ function fixPromptFor(finding) {
785
831
  'Then sign in as an ordinary user and open the pages that use these tables, and run this ' +
786
832
  'check again - the tables it could not test will be tested this time.',
787
833
  ]
834
+ : finding.kind === 'role' && finding.takeover
835
+ ? [
836
+ 'My app has a permissions problem - a member can take over another member\'s place.',
837
+ '',
838
+ 'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
839
+ finding.roleColumn + '" is "' + finding.who + '" can update another member\'s row in "' + finding.via +
840
+ '" and set "' + finding.personColumn + '" to their own id - taking that member\'s place and role, an ' +
841
+ 'admin\'s or the owner\'s included. This was proved on a copy of the database.',
842
+ '',
843
+ 'Fix the rules on "' + finding.via + '" that cover UPDATE or ALL: nobody may change "' + finding.personColumn +
844
+ '" or the team a membership belongs to on an existing row - adding or removing members is how membership ' +
845
+ 'changes. Let only the roles that manage the team (usually owner and admin) update membership rows at ' +
846
+ 'all, check that in WITH CHECK as well as USING, and never use WITH CHECK (true). If members only need to ' +
847
+ 'change something of their own, allow exactly that column on their own row and nothing else.',
848
+ '',
849
+ 'Then check every function that updates "' + finding.via + '" for the same thing.',
850
+ ]
788
851
  : finding.kind === 'role'
789
852
  ? [
790
853
  'My app may have a permissions problem - please check it rather than assume it.',
@@ -830,11 +893,14 @@ function fixPromptFor(finding) {
830
893
  'My app may have a storage problem - please check it rather than assume it.',
831
894
  '',
832
895
  'The Supabase Storage bucket "' + finding.bucket + '" is public. Anyone with a file\'s URL can ' +
833
- 'download it without logging in, and the storage.objects ' +
834
- ((finding.rules || []).length === 1 ? 'policy that names' : 'policies that name') + ' this bucket (' +
835
- listOf((finding.rules || []).map((r) => '"' + r + '"')) + ') ' +
836
- ((finding.rules || []).length === 1 ? 'is' : 'are') + ' not checked for public URLs - so ' +
837
- ((finding.rules || []).length === 1 ? 'it protects' : 'they protect') + ' nothing while it is public.',
896
+ 'download it without logging in' + (finding.noRule
897
+ ? ', and no rule on storage.objects says who may read it - its name suggests its files are not ' +
898
+ 'meant for everyone.'
899
+ : ', and the storage.objects ' +
900
+ ((finding.rules || []).length === 1 ? 'policy that names' : 'policies that name') + ' this bucket (' +
901
+ listOf((finding.rules || []).map((r) => '"' + r + '"')) + ') ' +
902
+ ((finding.rules || []).length === 1 ? 'is' : 'are') + ' not checked for public URLs - so ' +
903
+ ((finding.rules || []).length === 1 ? 'it protects' : 'they protect') + ' nothing while it is public.'),
838
904
  '',
839
905
  'First tell me: is every file in this bucket meant to be downloadable by anyone? If yes, change nothing.',
840
906
  '',
@@ -843,8 +909,11 @@ function fixPromptFor(finding) {
843
909
  'change the app to fetch these files with signed URLs (createSignedUrl) instead of getPublicUrl, ' +
844
910
  'or the links it shows will stop working.',
845
911
  '',
846
- 'Keep the read rule: once the bucket is private it is what decides who gets a file. Check that it ' +
847
- 'only lets in the right people.',
912
+ finding.noRule
913
+ ? 'Once it is private, add a read rule on storage.objects for this bucket that lets in only the ' +
914
+ 'people a file belongs to - without one, nobody but your server can read them.'
915
+ : 'Keep the read rule: once the bucket is private it is what decides who gets a file. Check that it ' +
916
+ 'only lets in the right people.',
848
917
  '',
849
918
  'Then look at every other bucket for the same thing.',
850
919
  ]
@@ -863,6 +932,16 @@ function fixPromptFor(finding) {
863
932
  'backend key, the way an admin-only function like this should be reached.',
864
933
  '',
865
934
  ].concat(
935
+ finding.nullSlips
936
+ ? [
937
+ 'Its role check lets through anyone with no role at all: the helper returns NULL for them, and ' +
938
+ 'NULL NOT IN (...) - or NULL <> ... - is not true, so the IF never raises. Write the check so ' +
939
+ 'NULL cannot pass, for example IF coalesce(<the helper>(...), \'none\') NOT IN (...) THEN RAISE, ' +
940
+ 'or IF <the helper>(...) IS NULL OR <the helper>(...) NOT IN (...) THEN RAISE - and check every ' +
941
+ 'other function that uses the same helper the same way.',
942
+ '',
943
+ ]
944
+ : [],
866
945
  finding.hasFixedSearchPath
867
946
  ? []
868
947
  : [
@@ -998,7 +1077,9 @@ function fixPromptFor(finding) {
998
1077
  'The "' + finding.table + '" table ' +
999
1078
  (finding.kind === 'exposed'
1000
1079
  ? 'can be read by anyone who is not logged in, because ' + cause.short + '.'
1001
- : 'lets one signed-in user read rows belonging to a different user, because ' + cause.short + '.'),
1080
+ : finding.tenant
1081
+ ? 'lets a signed-in user read rows belonging to a different organization, because ' + cause.short + '.'
1082
+ : 'lets one signed-in user read rows belonging to a different user, because ' + cause.short + '.'),
1002
1083
  '',
1003
1084
  ].concat(
1004
1085
  serverOnlyLines(finding) || [
@@ -1058,11 +1139,22 @@ function describe(finding) {
1058
1139
  readable: finding.readable,
1059
1140
  secrets: finding.secrets,
1060
1141
  bucket: finding.bucket,
1142
+ // An organization's rows read from outside it, and a member taking over
1143
+ // another member's row: kept so the re-check and the read-back word them
1144
+ // the same way the report did.
1145
+ tenant: finding.tenant,
1146
+ takeover: finding.takeover,
1147
+ personColumn: finding.personColumn,
1148
+ nullSlips: finding.nullSlips,
1149
+ noRule: finding.noRule,
1061
1150
  proof: finding.kind === 'recursive'
1062
1151
  ? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
1063
1152
  loopError(finding) + '.'
1064
1153
  : finding.kind === 'privileged'
1065
1154
  ? 'I did not call "' + finding.fn + '". This is a reach that exists in the grants, not a break I ran.'
1155
+ : finding.kind === 'role' && finding.takeover
1156
+ ? 'As ' + aRole(finding.who) + ' who had just joined, I put my own id on the other person\'s row of "' +
1157
+ finding.via + '", which I could not touch before joining. It was undone.'
1066
1158
  : finding.kind === 'role'
1067
1159
  ? 'As ' + aRole(finding.who) + ' who had just joined, I ' +
1068
1160
  listOf((finding.can || []).map((what) => {
@@ -1072,6 +1164,8 @@ function describe(finding) {
1072
1164
  : finding.kind === 'teamread'
1073
1165
  ? 'As ' + aRole(finding.who) + ' who had just joined, I read ' + (finding.readable || 1) +
1074
1166
  ((finding.readable || 1) === 1 ? ' row' : ' rows') + ' of "' + finding.table + '" that I could not read before joining.'
1167
+ : finding.kind === 'bucket' && finding.noRule
1168
+ ? 'I read that "' + finding.bucket + '" is public and that no rule says who may read it. I did not open any file.'
1075
1169
  : finding.kind === 'bucket'
1076
1170
  ? 'I read that "' + finding.bucket + '" is public and that ' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
1077
1171
  ' says who may read it. I did not open any file.'
package/installer.js CHANGED
@@ -213,6 +213,234 @@ async function configureReporting(client, schema, reportTo) {
213
213
  return { endpoint: reportTo.endpoint };
214
214
  }
215
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;
442
+ }
443
+
216
444
  /** A string, quoted the way Postgres wants it inside another statement. */
217
445
  function literal(text) {
218
446
  return "'" + String(text).split("'").join("''") + "'";
@@ -314,4 +542,8 @@ module.exports = {
314
542
  extensionState: extensionState,
315
543
  configureReporting: configureReporting,
316
544
  jobCommand: jobCommand,
545
+ installTemplate: installTemplate,
546
+ installScript: installScript,
547
+ uninstallScript: uninstallScript,
548
+ SLOTS: SLOTS,
317
549
  };
package/intro.js CHANGED
@@ -174,11 +174,20 @@ function installConsentLines(schema, source, at, reporting) {
174
174
  ];
175
175
  }
176
176
 
177
- /** Asks a question and hands back what was typed. */
178
- function ask(question) {
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 };
179
183
  return new Promise((resolve) => {
180
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
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
+ });
181
189
  rl.question(question, (answer) => {
190
+ answered = true;
182
191
  rl.close();
183
192
  resolve(String(answer || '').trim());
184
193
  });
@@ -235,9 +244,24 @@ function isYes(answer) {
235
244
  return said === 'y' || said === 'yes';
236
245
  }
237
246
 
238
- /** Yes or no, where anything that is not a clear yes is a no. */
239
- async function askYesNo(question) {
240
- return isYes(await ask(question));
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
+ }
241
265
  }
242
266
 
243
267
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon-night",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
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,7 +31,7 @@
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 && node report.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 && node tenants.check.js && node takeover.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",
@@ -74,7 +74,10 @@
74
74
  "check:engines": "node engines.check.js",
75
75
  "check:installer": "node installer.check.js",
76
76
  "check:roles": "node roles.check.js",
77
- "check:buckets": "node buckets.check.js"
77
+ "check:buckets": "node buckets.check.js",
78
+ "check:installscript": "node installscript.check.js",
79
+ "check:tenants": "node tenants.check.js",
80
+ "check:takeover": "node takeover.check.js"
78
81
  },
79
82
  "keywords": [
80
83
  "supabase",
package/scan.js CHANGED
@@ -505,6 +505,8 @@ function privilegedOf(plan) {
505
505
  args: fn.args,
506
506
  writes: fn.writes,
507
507
  hasFixedSearchPath: fn.hasFixedSearchPath,
508
+ // Its only check at the door is one a caller with no role slips past.
509
+ nullSlips: fn.nullSlips === true,
508
510
  columns: [],
509
511
  }));
510
512
  }
@@ -536,10 +538,19 @@ function bucketsOf(plan) {
536
538
  table: b.name,
537
539
  bucket: b.id,
538
540
  rules: (b.readRules || []).filter((rule) => narrows(rule, b.id)).map((rule) => rule.name),
541
+ // No read rule at all, and a name that says what is in it is not for
542
+ // everyone. Found on a blind test (HarborLine): customer-exports was
543
+ // public, and its one read rule had failed to load (a text compared to a
544
+ // uuid) - with nothing to read, the bucket was never mentioned.
545
+ noRule: !(b.readRules || []).length && PRIVATE_BUCKET.test(String(b.name)),
539
546
  columns: [],
540
- })).filter((f) => f.rules.length);
547
+ })).filter((f) => f.rules.length || f.noRule);
541
548
  }
542
549
 
550
+ // Names that say a bucket's files are somebody's own business. Whole words
551
+ // only: "plain" or "avatars" say nothing, and stay quiet.
552
+ const PRIVATE_BUCKET = /(^|[-_.])(exports?|private|documents?|docs|invoices?|contracts?|backups?|reports?|receipts?|statements?|payouts?|evidence|kyc|passports?|medical|records?|confidential|internal|secrets?|attachments?)([-_.]|$)/i;
553
+
543
554
  /**
544
555
  * What each finding needs to know about its table for its fix to be right:
545
556
  * whether the app signs people in through Supabase, whether a row says whose
@@ -619,14 +630,17 @@ function nightlyRecord(latest, readAt, plan) {
619
630
  if (!run) return record;
620
631
  // The night's findings, told as the night's: tagged with what their table
621
632
  // needs for a right fix when the schema could be read, and joined by the
622
- // functions anyone can call - read now, when this is asked, because the
623
- // engine inside the database does not look at them.
633
+ // functions anyone can call and the public buckets - read now, when this is
634
+ // asked, which is newer than the night. The night records them too since
635
+ // 0.1.32 (for the dashboard), so where they can be read now the night's own
636
+ // are left out rather than said twice; where they cannot, the night's stand.
624
637
  const tag = plan ? taggerFor(plan, plan.tables) : (f) => f;
625
638
  // A loop the engine could not name ("stack depth limit exceeded") is put
626
639
  // on the tables whose own rule reads back into them, read from the shape.
627
640
  const loops = plan ? loopingTables(plan) : [];
628
641
  const night = [];
629
642
  for (const f of run.findings || []) {
643
+ if (plan && (f.kind === 'privileged' || f.kind === 'bucket')) continue;
630
644
  const at = f.kind === 'recursive' && !f.table ? (loops.length ? loops : ['one of your tables']) : [f.table];
631
645
  for (const table of at) night.push(tag(Object.assign({}, f, { table: table, fromNight: true })));
632
646
  }