kryptheon-night 0.1.14 → 0.1.22

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.
@@ -32,8 +32,8 @@ const intro = require('../intro.js');
32
32
  const trouble = require('../trouble.js');
33
33
  const { howToConnect } = require('../connect.js');
34
34
  const installer = require('../installer.js');
35
- const finding = require('../finding.js');
36
35
  const outside = require('../outside.js');
36
+ const schema = require('../schema.js');
37
37
 
38
38
  const line = (text) => process.stdout.write(text + '\n');
39
39
  const fail = (text) => process.stderr.write(text + '\n');
@@ -88,7 +88,7 @@ function usage() {
88
88
  // names a schema: `public` is the schema almost everybody has, and a bare
89
89
  // word that is not one of these is still read as a schema so that
90
90
  // `kryptheon-night public` keeps working.
91
- const VERBS = ['install', 'uninstall', 'status', 'night'];
91
+ const VERBS = ['install', 'uninstall', 'status', 'night', 'words'];
92
92
 
93
93
  /** What was asked for, and what was left to the default. */
94
94
  function readArgs(argv) {
@@ -118,6 +118,50 @@ function readArgs(argv) {
118
118
  return asked;
119
119
  }
120
120
 
121
+ /**
122
+ * Keeps what the nightly run last said where the kryptheon dashboard reads
123
+ * it, so the answer waiting in the database is also on the page - not only
124
+ * what was last run from this terminal. Never fails anything: the person has
125
+ * their answer already, and a folder that cannot be written is not theirs to
126
+ * debug here.
127
+ */
128
+ function keepTheNight(record) {
129
+ try {
130
+ scanner.saveNightly(scanner.nightlyFile(), record);
131
+ } catch (err) {
132
+ /* the dashboard keeps its older copy, which carries its own date */
133
+ }
134
+ }
135
+
136
+ /**
137
+ * The night's answer, made whole: its findings told with what their tables
138
+ * need for a right fix, plus the functions anyone can call - both read from
139
+ * the shape now, read only. Without the shape it is still the night's answer,
140
+ * just less exact.
141
+ */
142
+ async function nightRecordFrom(client, latest) {
143
+ let plan = null;
144
+ const run = latest && latest.run;
145
+ if (run && !run.stopped) {
146
+ try {
147
+ plan = await schema.readSchema(client, run.source);
148
+ } catch (err) {
149
+ plan = null;
150
+ }
151
+ }
152
+ return scanner.nightlyRecord(latest, new Date().toISOString(), plan);
153
+ }
154
+
155
+ /** Reads and keeps it, for the doors that do not already hold the answer. */
156
+ async function readAndKeepTheNight(client) {
157
+ try {
158
+ keepTheNight(await nightRecordFrom(client, await installer.latestRun(client, {})));
159
+ } catch (err) {
160
+ /* as above */
161
+ }
162
+ }
163
+
164
+
121
165
  /**
122
166
  * The three doors that are not a scan.
123
167
  *
@@ -127,6 +171,7 @@ function readArgs(argv) {
127
171
  */
128
172
  async function runVerb(command, client, target) {
129
173
  if (command === 'status') {
174
+ await readAndKeepTheNight(client);
130
175
  const where = await installer.status(client, {});
131
176
  if (!where) {
132
177
  line(' The nightly run is not installed in this database.');
@@ -183,6 +228,7 @@ async function runVerb(command, client, target) {
183
228
  return 0;
184
229
  }
185
230
  const removed = await installer.uninstall(client, {});
231
+ await readAndKeepTheNight(client);
186
232
  line(' Removed.');
187
233
  line('');
188
234
  line(' the nightly job ' + (removed.job ? 'gone' : 'there was none'));
@@ -195,7 +241,10 @@ async function runVerb(command, client, target) {
195
241
  }
196
242
 
197
243
  if (command === 'night') {
198
- const where = await installer.status(client, {});
244
+ const latest = await installer.latestRun(client, {});
245
+ const record = await nightRecordFrom(client, latest);
246
+ keepTheNight(record);
247
+ const where = latest.where;
199
248
  if (!where) {
200
249
  line(' The nightly run is not installed in this database, so there is');
201
250
  line(' nothing to read back.');
@@ -205,31 +254,31 @@ async function runVerb(command, client, target) {
205
254
  line('');
206
255
  return 2;
207
256
  }
208
- const quoted = '"' + String(where.schema).split('"').join('""') + '"';
209
- const { rows } = await client.query(
210
- 'SELECT ran_at, source, stopped, attacks_run, findings, not_checked ' +
211
- 'FROM ' + quoted + '.runs ORDER BY ran_at DESC LIMIT 1',
212
- );
213
- if (!rows.length) {
257
+ if (!latest.run) {
214
258
  // Installed and never run is not the same as run and found nothing, and
215
259
  // saying "nothing got through" here would be the worst sentence in the
216
260
  // product printed about a night that never happened.
217
261
  line(' It is installed, and it has not run yet.');
218
262
  line('');
219
- line(' The first run is at ' + (where.job ? where.job.schedule : 'whenever it is scheduled') + '.');
263
+ line(' It runs ' + scanner.scheduleTimes(where.job ? where.job.schedule : null) + '.');
220
264
  line('');
221
265
  return 0;
222
266
  }
223
- const run = rows[0];
224
- line(' From the night of ' + new Date(run.ran_at).toISOString().slice(0, 16).replace('T', ' ') +
225
- ', on "' + run.source + '":');
267
+ const run = latest.run;
268
+ line(' From the night of ' + scanner.bothTimes(run.ran_at) + ', on "' + run.source + '".');
269
+ if (where.job) line(' It runs ' + scanner.scheduleTimes(where.job.schedule) + '.');
270
+ // The one part not from the night: the engine inside the database does
271
+ // not look at functions, so they are read now and said to be.
272
+ if (record.functionsReadAt) {
273
+ line(' Functions anyone can call were looked at just now, not overnight.');
274
+ }
226
275
  scanner.report({
227
- stopped: run.stopped,
228
- attacksRun: run.attacks_run,
229
- notChecked: run.not_checked || [],
230
- findings: finding.describeAll(run.findings || []),
276
+ stopped: record.stopped,
277
+ attacksRun: record.attacksRun,
278
+ notChecked: record.notChecked || [],
279
+ findings: record.findings || [],
231
280
  });
232
- return run.stopped ? 2 : (run.findings || []).length ? 1 : 0;
281
+ return run.stopped ? 2 : scanner.exitCodeFor(record);
233
282
  }
234
283
 
235
284
  throw new Error('there is no command called ' + command);
@@ -373,6 +422,23 @@ async function runOutside(appUrl) {
373
422
  return result.findings.length ? 1 : 0;
374
423
  }
375
424
 
425
+ /** Everything the dashboard needs to say what this command would say. */
426
+ function wordsFor(target, connection) {
427
+ const given = String(connection || '').trim();
428
+ const unusable = given ? trouble.readConnectionString(given) : null;
429
+ return {
430
+ help: intro.whereToFindIt(),
431
+ consent: intro.consentLines(target),
432
+ installConsent: intro.installConsentLines(installer.SCHEMA, target, installer.AT),
433
+ // pg_cron reads this in the database's own time zone, which is UTC on
434
+ // Supabase; the dashboard turns it into the person's local time.
435
+ nightlyAt: installer.AT,
436
+ given: Boolean(given),
437
+ unusable: unusable,
438
+ warning: given && !unusable ? trouble.poolerWarning(given) : null,
439
+ };
440
+ }
441
+
376
442
  /** Prints a block of plain lines with the indent the rest of the report uses. */
377
443
  function block(lines, write) {
378
444
  (write || line)('');
@@ -394,6 +460,17 @@ async function main() {
394
460
  process.exit(2);
395
461
  }
396
462
 
463
+ // The words this command says, as JSON, for the kryptheon dashboard to show
464
+ // on its own buttons: the help for finding the string, both consent
465
+ // screens, and - when KN_DATABASE_URL is given - what is wrong with it or
466
+ // worth warning about. Never connects, and never prints the string: the
467
+ // warnings name the host at most, and a string that cannot be used is
468
+ // described, not repeated.
469
+ if (asked.command === 'words') {
470
+ process.stdout.write(JSON.stringify(wordsFor(asked.schema || 'public', process.env.KN_DATABASE_URL || '')) + '\n');
471
+ return;
472
+ }
473
+
397
474
  // The outside attack. Needs no connection string, no copy and no yes: it
398
475
  // only asks the live app what it already answers any visitor, and asks for
399
476
  // counts, never rows. So nothing below - the string, the consent screen,
@@ -583,6 +660,9 @@ async function main() {
583
660
  // think about it again, which is exactly the app that is unprotected
584
661
  // three months later. The tool already knows whether the nightly run is
585
662
  // there. So it asks.
663
+ // Whatever the nightly run last said is kept for the dashboard too, so
664
+ // the page shows both answers and says which is which.
665
+ await readAndKeepTheNight(client);
586
666
  if (!result.stopped) await offerTheNight(client, target, canAsk);
587
667
  } catch (err) {
588
668
  // Anything that went wrong mid-scan. The copy has already been dropped by
package/finding.js CHANGED
@@ -448,7 +448,10 @@ function bodyFor(finding, contents) {
448
448
  }
449
449
  return (
450
450
  'Anyone on the internet, without logging in and without an account, can read ' +
451
- 'this table. I did it myself just now and got back ' + finding.readable + ' ' + rowWord +
451
+ // Read back the next morning, "just now" would be untrue: it was the
452
+ // nightly run, hours earlier.
453
+ 'this table. ' + (finding.fromNight ? 'The nightly run did it on a copy and got back ' : 'I did it myself just now and got back ') +
454
+ finding.readable + ' ' + rowWord +
452
455
  (holds ? ', including ' + holds : '') + '.'
453
456
  );
454
457
  }
@@ -523,8 +526,32 @@ function serverOnlyLines(finding) {
523
526
  // column saying whose row it is. The assistant applying it had to correct
524
527
  // the prompt; a less careful one would have locked the table away from the
525
528
  // members who use it.
526
- const tenant = finding.authTied !== false && finding.owned === false &&
527
- (finding.columns || []).find((c) => TENANT_COLUMN.test(String(c)));
529
+ const team = finding.authTied !== false && (finding.columns || []).find((c) => TENANT_COLUMN.test(String(c)));
530
+ const maker = finding.owner || finding.ownedBy;
531
+ // A team's row that also says who made it - created_by beside workspace_id.
532
+ // Found on a benchmark: the prompt said to compare created_by to auth.uid()
533
+ // for reading, which hides every teammate's rows and breaks the shared
534
+ // view the table exists for. Who made a row decides who may write it
535
+ // under their name, not who may see it.
536
+ if (team && finding.owned !== false && maker) {
537
+ return [
538
+ 'Rows in "' + table + '" belong to a team ("' + team + '"), and "' + maker + '" only records who ' +
539
+ 'made each one. So reading should be decided by team membership: let a signed-in user read a ' +
540
+ 'row when they are a member of that row\'s "' + team + '" - checked through your membership ' +
541
+ 'table, or a helper function that asks it - and give logged-out visitors nothing. Do not ' +
542
+ 'compare "' + maker + '" to auth.uid() for reading: that hides teammates\' rows from each other ' +
543
+ 'and breaks the feature.',
544
+ '',
545
+ 'Write separate rules for reading, inserting, updating and deleting. Inserting: WITH CHECK that ' +
546
+ 'the user is a member of the row\'s "' + team + '" AND "' + maker + '" = auth.uid(), so nobody ' +
547
+ 'writes a row under somebody else\'s name or into a team they are not in. Updating and ' +
548
+ 'deleting: the membership check in USING (and WITH CHECK for updating), and decide whether any ' +
549
+ 'member may do it or only the row\'s "' + maker + '" or an admin. If only your server writes ' +
550
+ 'this table, add no write rules at all.',
551
+ '',
552
+ ];
553
+ }
554
+ const tenant = finding.owned === false && team;
528
555
  if (tenant) {
529
556
  return [
530
557
  'Rows in "' + table + '" belong to an organization ("' + tenant + '"), not to one person. So the ' +
@@ -563,6 +590,18 @@ function serverOnlyLines(finding) {
563
590
  ];
564
591
  }
565
592
 
593
+ /**
594
+ * The opening of a rules fix. "Switch row level security on" is said only
595
+ * when it is off: told it about a table where it was already on, the person
596
+ * looks for a switch that is already flipped and misses the rule that is the
597
+ * real problem.
598
+ */
599
+ function switchOn(finding) {
600
+ return finding.rlsEnabled
601
+ ? 'Row level security is already on for this table, so the rules on it are the problem: '
602
+ : 'Switch row level security on for this table, then ';
603
+ }
604
+
566
605
  /**
567
606
  * One prompt for everything wrong with one table's rules - read and write,
568
607
  * strangers and customers - because it is one rule to replace, not four.
@@ -597,9 +636,8 @@ function groupPromptFor(members) {
597
636
  '',
598
637
  ].concat(
599
638
  serverOnlyLines(lead) || [
600
- 'Switch row level security on for this table, remove the rule that lets everyone in, and ' +
601
- 'write separate rules for reading, inserting, updating and deleting. A rule written FOR ALL ' +
602
- 'covers far more than reading.',
639
+ switchOn(lead) + 'remove the rule that lets everyone in, and write separate rules for reading, ' +
640
+ 'inserting, updating and deleting. A rule written FOR ALL covers far more than reading.',
603
641
  '',
604
642
  'Each of them should compare ' + ownerWords + ' to the id of the signed-in user (auth.uid()): ' +
605
643
  'USING for reading, updating and deleting, WITH CHECK for inserting and updating, so nobody ' +
@@ -706,9 +744,9 @@ function fixPromptFor(finding) {
706
744
  '',
707
745
  ].concat(
708
746
  serverOnlyLines(finding) || [
709
- 'Switch row level security on for this table, then write separate rules for ' +
710
- 'reading, inserting, updating and deleting. A rule written FOR SELECT does ' +
711
- 'not cover writes, and a rule written FOR ALL covers far more than reading.',
747
+ switchOn(finding) + 'write separate rules for reading, inserting, updating and deleting. ' +
748
+ 'A rule written FOR SELECT does not cover writes, and a rule written FOR ALL covers ' +
749
+ 'far more than reading.',
712
750
  '',
713
751
  'For insert and update, use WITH CHECK comparing ' + owner +
714
752
  ' to the id of the signed-in user, so nobody can write a row under somebody ' +
package/installer.js CHANGED
@@ -229,8 +229,24 @@ async function uninstall(client, options) {
229
229
  return removed;
230
230
  }
231
231
 
232
+ /**
233
+ * What the nightly run last said, read out of the database: whether it is
234
+ * installed at all, how it is scheduled, and its newest row in runs - or null
235
+ * for a run that has not happened yet. Read only.
236
+ */
237
+ async function latestRun(client, options) {
238
+ const where = await status(client, options);
239
+ if (!where) return { installed: false, where: null, run: null };
240
+ const { rows } = await client.query(
241
+ 'SELECT ran_at, source, stopped, attacks_run, findings, not_checked FROM ' +
242
+ quote(where.schema) + '.runs ORDER BY ran_at DESC, id DESC LIMIT 1',
243
+ );
244
+ return { installed: true, where: where, run: rows[0] || null };
245
+ }
246
+
232
247
  module.exports = {
233
248
  SCHEMA: SCHEMA,
249
+ latestRun: latestRun,
234
250
  AT: AT,
235
251
  install: install,
236
252
  uninstall: uninstall,
package/intro.js CHANGED
@@ -28,15 +28,23 @@ const trouble = require('./trouble.js');
28
28
  * their users end up on the same page by a different door.
29
29
  */
30
30
  function whereToFindIt() {
31
+ // The session pooler, and said as such: the direct connection answers only
32
+ // over IPv6, which many home networks do not have, and the transaction
33
+ // pooler on 6543 cannot hold these attacks together. The kryptheon
34
+ // dashboard shows these same steps; kryptheon-night words hands them over.
31
35
  return [
32
36
  'Where to find it:',
33
37
  '',
34
- ' Supabase supabase.com/dashboard -> your project -> the gear icon',
35
- ' (Project Settings) at the bottom left -> Database ->',
36
- ' scroll to "Connection string" -> the URI tab -> Copy.',
38
+ ' Supabase 1. supabase.com/dashboard -> open your project.',
39
+ ' 2. Press "Connect" at the top of the page.',
40
+ ' 3. Choose "Session pooler" - the one on port 5432.',
41
+ ' 4. Copy the URI. It starts postgresql://',
42
+ ' 5. Replace [YOUR-PASSWORD] in it with your database password.',
43
+ ' Forgotten it? Project Settings -> Database -> Reset',
44
+ ' database password.',
37
45
  '',
38
- ' Replace [YOUR-PASSWORD] in it with your database password.',
39
- ' It is on that same page under "Database password".',
46
+ ' Not port 6543 (the transaction pooler), and not the direct',
47
+ ' db.<project>.supabase.co address - both are warned about.',
40
48
  '',
41
49
  ' Lovable your app uses Supabase underneath. Open the Supabase',
42
50
  ' and Bolt project it made for you and follow the lines above.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon-night",
3
- "version": "0.1.14",
3
+ "version": "0.1.22",
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 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 cli.check.js && node loop.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",
@@ -55,6 +55,8 @@
55
55
  "check:privileged": "node privileged.check.js",
56
56
  "check:recursion": "node recursion.check.js",
57
57
  "check:guarded": "node guarded.check.js",
58
+ "check:nightly": "node nightly.check.js",
59
+ "check:words": "node words.check.js",
58
60
  "check:cli": "node cli.check.js",
59
61
  "check:writes": "node tamper.check.js",
60
62
  "check:orphans": "node orphans.check.js",
package/scan.js CHANGED
@@ -385,28 +385,12 @@ async function scan(client, sourceSchema, options) {
385
385
  // version recorded only the flagged ones, so a revoke made the function
386
386
  // vanish from both lists and the fix came back as "could not confirm".
387
387
  for (const fn of plan.anonFunctions || []) attempted.push('privileged:' + fn.name);
388
- // Quiet: a yes/no about the caller, and a function that turns the caller
389
- // away before it does anything (schema.guardedAtTheDoor).
390
- const privileged = (plan.anonFunctions || []).filter((fn) => fn.callable && !fn.aboutCaller && !fn.guarded).map((fn) => ({
391
- kind: 'privileged',
392
- fn: fn.name,
393
- table: fn.name,
394
- args: fn.args,
395
- writes: fn.writes,
396
- hasFixedSearchPath: fn.hasFixedSearchPath,
397
- columns: [],
398
- }));
388
+ const privileged = privilegedOf(plan);
399
389
 
400
- // Whether each table is tied to Supabase's sign-in decides what its fix
401
- // can be. Read from the original schema: the copy points auth.users keys
402
- // at a stand-in, and would call every table untied.
403
- const ties = finding.authTiesOf(plan);
404
- // And whether a row says whose it is: with no such column, no rule can
405
- // tie it to the person asking. Read from the copy, the way seeding did.
406
- const owned = new Map(theirs.map((table) => [table.name, Boolean(attack.ownerColumn(table))]));
407
- const tagged = (f) => Object.assign({}, f,
408
- ties.has(f.table) ? { authTied: ties.get(f.table) } : {},
409
- owned.has(f.table) ? { owned: owned.get(f.table) } : {});
390
+ // Ties read from the original schema: the copy points auth.users keys at
391
+ // a stand-in, and would call every table untied. Owners read from the
392
+ // copy, the way seeding did.
393
+ const tagged = taggerFor(plan, theirs);
410
394
 
411
395
  return {
412
396
  stopped: null,
@@ -438,6 +422,128 @@ function exitCodeFor(result) {
438
422
  return (result.findings || []).some((f) => f.status !== 'verification required') ? 1 : 0;
439
423
  }
440
424
 
425
+ /**
426
+ * Functions an anonymous visitor can call that run past the rules. Read from
427
+ * the shape, not attacked. Quiet: a yes/no about the caller, and a function
428
+ * that turns the caller away before it does anything (schema.guardedAtTheDoor).
429
+ */
430
+ function privilegedOf(plan) {
431
+ return ((plan && plan.anonFunctions) || []).filter((fn) => fn.callable && !fn.aboutCaller && !fn.guarded).map((fn) => ({
432
+ kind: 'privileged',
433
+ fn: fn.name,
434
+ table: fn.name,
435
+ args: fn.args,
436
+ writes: fn.writes,
437
+ hasFixedSearchPath: fn.hasFixedSearchPath,
438
+ columns: [],
439
+ }));
440
+ }
441
+
442
+ /**
443
+ * What each finding needs to know about its table for its fix to be right:
444
+ * whether the app signs people in through Supabase, whether a row says whose
445
+ * it is, and which column that is. Without these a prompt falls back to "the
446
+ * column that says who each row belongs to" - found on a nightly read-back,
447
+ * whose findings came from the database without them.
448
+ */
449
+ function taggerFor(plan, tables) {
450
+ const ties = finding.authTiesOf(plan);
451
+ const owned = new Map((tables || []).map((table) => [table.name, attack.ownerColumn(table)]));
452
+ return (f) => Object.assign({}, f,
453
+ ties.has(f.table) ? { authTied: ties.get(f.table) } : {},
454
+ owned.has(f.table) ? { owned: Boolean(owned.get(f.table)) } : {},
455
+ owned.get(f.table) ? { ownedBy: owned.get(f.table) } : {});
456
+ }
457
+
458
+ /**
459
+ * What the nightly engine wrote down as not tried, in the shape a scan uses.
460
+ * It names a column's check as table and column apart, with no attack named,
461
+ * so "export_jobs.access_token could not be raced" was counted as a whole
462
+ * table not checked - and listed without its column.
463
+ */
464
+ function notCheckedFromNight(entries) {
465
+ return (entries || []).map((e) => {
466
+ if (!e || e.key || !e.column) return e;
467
+ const kind = /two requests at the same instant/.test(String(e.why)) ? 'duplicated' : 'orphaned';
468
+ return { table: e.table + '.' + e.column, key: kind + ':' + e.table + ':' + e.column, why: e.why };
469
+ });
470
+ }
471
+
472
+ /**
473
+ * A moment of the night, said in UTC - which is what the database runs on -
474
+ * and in this computer's own time, so 03:00 is not read as three at night
475
+ * here. Found on a real read-back: "the night of 2026-10-01 03:00" was 08:30
476
+ * in India, and nothing said so.
477
+ */
478
+ function bothTimes(iso) {
479
+ const at = new Date(iso);
480
+ return at.toISOString().slice(0, 16).replace('T', ' ') + ' UTC (' +
481
+ at.toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) + ' your time)';
482
+ }
483
+
484
+ /** "0 3 * * *" as a time of day in both, or the schedule as written. */
485
+ function scheduleTimes(cron) {
486
+ const m = /^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*$/.exec(String(cron || '').trim());
487
+ if (!m) return String(cron || 'whenever it is scheduled');
488
+ const now = new Date();
489
+ const at = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), Number(m[2]), Number(m[1])));
490
+ return 'every night at ' + at.toISOString().slice(11, 16) + ' UTC (' +
491
+ at.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) + ' your time)';
492
+ }
493
+
494
+ /** Where the nightly run's last answer is kept for the dashboard. */
495
+ function nightlyFile() {
496
+ return store.open(process.cwd()).nightNightly;
497
+ }
498
+
499
+ /**
500
+ * The nightly run's last answer, in the shape the dashboard reads: the same
501
+ * described findings a scan saves, and the three states that are not the same
502
+ * as "nothing found" - not installed, installed and never run, and stopped -
503
+ * said as themselves. `latest` is installer.latestRun's answer.
504
+ */
505
+ function nightlyRecord(latest, readAt, plan) {
506
+ const at = (value) => (value ? new Date(value).toISOString() : null);
507
+ if (!latest || !latest.installed) return { readAt: readAt, installed: false };
508
+ const where = latest.where || {};
509
+ const record = {
510
+ readAt: readAt,
511
+ installed: true,
512
+ scheduled: where.job ? where.job.schedule : null,
513
+ active: Boolean(where.job && where.job.active),
514
+ source: where.source || null,
515
+ ranAt: null,
516
+ };
517
+ const run = latest.run;
518
+ if (!run) return record;
519
+ // The night's findings, told as the night's: tagged with what their table
520
+ // needs for a right fix when the schema could be read, and joined by the
521
+ // functions anyone can call - read now, when this is asked, because the
522
+ // engine inside the database does not look at them.
523
+ const tag = plan ? taggerFor(plan, plan.tables) : (f) => f;
524
+ const night = (run.findings || []).map((f) => tag(Object.assign({}, f, { fromNight: true })));
525
+ const now = plan ? privilegedOf(plan).map(tag) : [];
526
+ return Object.assign(record, {
527
+ ranAt: at(run.ran_at),
528
+ source: run.source,
529
+ stopped: run.stopped || null,
530
+ attacksRun: run.attacks_run || 0,
531
+ notChecked: notCheckedFromNight(run.not_checked),
532
+ findings: run.stopped ? [] : finding.describeAll(night.concat(now)),
533
+ functionsReadAt: plan ? readAt : null,
534
+ });
535
+ }
536
+
537
+ /** Kept for the dashboard. A failure here never touches the run itself. */
538
+ function saveNightly(file, record) {
539
+ try {
540
+ fs.writeFileSync(file, JSON.stringify(record, null, 2));
541
+ return true;
542
+ } catch (err) {
543
+ return false;
544
+ }
545
+ }
546
+
441
547
  /** The last run, or null if there has not been one worth keeping. */
442
548
  function loadLastRun(file) {
443
549
  try {
@@ -506,6 +612,34 @@ function notTestedLines(result) {
506
612
  return lines;
507
613
  }
508
614
 
615
+ /**
616
+ * The warning above the verdict: how much went untested, counted honestly.
617
+ *
618
+ * Every entry used to be counted as a table. Found on a benchmark: one
619
+ * column's duplicate test did not run, and the top line said "1 table was
620
+ * NOT checked" about a table every other attack had covered - and a table
621
+ * with two attacks stuck was counted as two tables. An entry with no attack
622
+ * named is a whole table nothing could be put into; one with an attack named
623
+ * is part of a table.
624
+ */
625
+ function missedLines(notChecked) {
626
+ const tableOf = (entry) => String(entry.table).split('.')[0];
627
+ const whole = new Set(notChecked.filter((entry) => !entry.key).map(tableOf));
628
+ const parts = notChecked.filter((entry) => entry.key && !whole.has(tableOf(entry)));
629
+ const partly = new Set(parts.map(tableOf));
630
+ const tables = (n) => n + (n === 1 ? ' table' : ' tables');
631
+ const said = [];
632
+ if (whole.size) said.push(tables(whole.size) + (whole.size === 1 ? ' was' : ' were') + ' NOT checked');
633
+ if (parts.length) {
634
+ said.push(parts.length + (parts.length === 1 ? ' check' : ' checks') + ' on ' +
635
+ (whole.size ? tables(partly.size).replace(/ table/, ' other table') : tables(partly.size)) +
636
+ (parts.length === 1 ? ' was' : ' were') + ' NOT run');
637
+ }
638
+ const it = notChecked.length === 1 ? 'it' : 'them';
639
+ return wrap(said.join(', and ') + '. Whatever this report says next is not about ' + it + '. Treat ' +
640
+ it + ' as unknown, not as clear - the list, and why, is at the end.', 68).map((l) => ' ' + l);
641
+ }
642
+
509
643
  /** The report, as the person reads it in the morning. */
510
644
  function report(result) {
511
645
  if (result.stopped) {
@@ -530,11 +664,7 @@ function report(result) {
530
664
  const sayWhatWasMissed = () => {
531
665
  if (!notChecked.length) return;
532
666
  line('');
533
- line(' ' + notChecked.length + (notChecked.length === 1 ? ' table was' : ' tables were') +
534
- ' NOT checked. Whatever this report says next is not');
535
- line(' about ' + (notChecked.length === 1 ? 'it' : 'them') + '. Treat ' +
536
- (notChecked.length === 1 ? 'it' : 'them') + ' as unknown, not as clear - the list, and why, is');
537
- line(' at the end.');
667
+ missedLines(notChecked).forEach(line);
538
668
  };
539
669
 
540
670
  if (!result.findings.length) {
@@ -702,9 +832,18 @@ if (require.main === module) {
702
832
  module.exports = {
703
833
  scan: scan,
704
834
  report: report,
835
+ missedLines: missedLines,
705
836
  wrap: wrap,
706
837
  loadLastRun: loadLastRun,
707
838
  saveRun: saveRun,
839
+ nightlyFile: nightlyFile,
840
+ nightlyRecord: nightlyRecord,
841
+ bothTimes: bothTimes,
842
+ scheduleTimes: scheduleTimes,
843
+ notCheckedFromNight: notCheckedFromNight,
844
+ privilegedOf: privilegedOf,
845
+ taggerFor: taggerFor,
846
+ saveNightly: saveNightly,
708
847
  sweepOldCopies: sweepOldCopies,
709
848
  notTestedLines: notTestedLines,
710
849
  lastRunFile: lastRunFile,
package/store.js CHANGED
@@ -45,6 +45,7 @@ const FILES = {
45
45
  history: 'history.jsonl',
46
46
  testResults: 'test-results',
47
47
  nightLast: 'night-last.json',
48
+ nightNightly: 'night-nightly.json',
48
49
  codeFindings: 'code-findings.json',
49
50
  config: 'config.json',
50
51
  snapshot: 'snapshot.json',
@@ -132,6 +133,7 @@ function pathsIn(dir) {
132
133
  history: path.join(dir, FILES.history),
133
134
  testResults: path.join(dir, FILES.testResults),
134
135
  nightLast: path.join(dir, FILES.nightLast),
136
+ nightNightly: path.join(dir, FILES.nightNightly),
135
137
  codeFindings: path.join(dir, FILES.codeFindings),
136
138
  config: path.join(dir, FILES.config),
137
139
  snapshot: path.join(dir, FILES.snapshot),
package/trouble.js CHANGED
@@ -189,8 +189,22 @@ function poolerWarning(raw) {
189
189
  'That is the transaction pooler (port 6543). It gives every statement a',
190
190
  'different connection, and these attacks have to stay on one.',
191
191
  '',
192
- 'Use the session pooler or the direct connection instead - same page in',
193
- 'Supabase, the one whose port is 5432.',
192
+ 'Use the session pooler instead: Supabase -> Connect -> Session pooler,',
193
+ 'the one whose port is 5432.',
194
+ ];
195
+ }
196
+
197
+ // The direct connection answers only over IPv6 now. On a network without
198
+ // it the name does not resolve, and the failure reads like a typo. It works
199
+ // where IPv6 does, so this warns rather than refuses.
200
+ if (/^db\.[a-z0-9]+\.supabase\.(co|com)$/i.test(url.hostname)) {
201
+ return [
202
+ 'That is Supabase\'s direct connection (db.' + url.hostname.split('.')[1] + '.supabase.co). It',
203
+ 'answers only over IPv6, and many home and office networks have none - it',
204
+ 'then fails as if the address were wrong.',
205
+ '',
206
+ 'If it does, use the session pooler: Supabase -> Connect -> Session pooler,',
207
+ 'port 5432.',
194
208
  ];
195
209
  }
196
210
 
@@ -359,14 +373,19 @@ function explain(err, raw) {
359
373
  const ref = code === 'ENOTFOUND' ? supabaseDirectRef(raw) : null;
360
374
  if (ref) {
361
375
  return [
362
- 'I could not find that server - and I think I know why.',
376
+ 'I could not find that server. There are two likely reasons.',
377
+ '',
378
+ 'Most likely: that address is Supabase’s direct connection, and it now',
379
+ 'answers only over IPv6. Plenty of home and office networks have no IPv6',
380
+ 'at all, and on those the name does not resolve to anything.',
363
381
  '',
364
- 'That address is Supabase’s direct connection, and it now answers only',
365
- 'over IPv6. Plenty of home and office networks have no IPv6 at all, and on',
366
- 'those the name does not resolve to anything - which is what just',
367
- 'happened here.',
382
+ // A ref with one letter wrong fails exactly the same way, and blaming
383
+ // only IPv6 would send somebody to change networks over a typo.
384
+ 'Or the address is mistyped. Check that "' + ref + '" matches the project',
385
+ 'ref in Supabase -> Project Settings -> General; one wrong letter fails',
386
+ 'exactly the same way.',
368
387
  '',
369
- 'There is nothing wrong with your project or your password. Use the',
388
+ 'Your password has not been tried yet, so it is not the problem. Use the',
370
389
  'session pooler instead, which answers over the ordinary internet:',
371
390
  '',
372
391
  ' Supabase dashboard -> Project Settings -> Database ->',