kryptheon-night 0.1.14 → 0.1.19

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.
@@ -118,6 +118,30 @@ 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(latest) {
129
+ try {
130
+ scanner.saveNightly(scanner.nightlyFile(), scanner.nightlyRecord(latest, new Date().toISOString()));
131
+ } catch (err) {
132
+ /* the dashboard keeps its older copy, which carries its own date */
133
+ }
134
+ }
135
+
136
+ /** Reads and keeps it, for the doors that do not already hold the answer. */
137
+ async function readAndKeepTheNight(client) {
138
+ try {
139
+ keepTheNight(await installer.latestRun(client, {}));
140
+ } catch (err) {
141
+ /* as above */
142
+ }
143
+ }
144
+
121
145
  /**
122
146
  * The three doors that are not a scan.
123
147
  *
@@ -127,6 +151,7 @@ function readArgs(argv) {
127
151
  */
128
152
  async function runVerb(command, client, target) {
129
153
  if (command === 'status') {
154
+ await readAndKeepTheNight(client);
130
155
  const where = await installer.status(client, {});
131
156
  if (!where) {
132
157
  line(' The nightly run is not installed in this database.');
@@ -183,6 +208,7 @@ async function runVerb(command, client, target) {
183
208
  return 0;
184
209
  }
185
210
  const removed = await installer.uninstall(client, {});
211
+ await readAndKeepTheNight(client);
186
212
  line(' Removed.');
187
213
  line('');
188
214
  line(' the nightly job ' + (removed.job ? 'gone' : 'there was none'));
@@ -195,7 +221,9 @@ async function runVerb(command, client, target) {
195
221
  }
196
222
 
197
223
  if (command === 'night') {
198
- const where = await installer.status(client, {});
224
+ const latest = await installer.latestRun(client, {});
225
+ keepTheNight(latest);
226
+ const where = latest.where;
199
227
  if (!where) {
200
228
  line(' The nightly run is not installed in this database, so there is');
201
229
  line(' nothing to read back.');
@@ -205,12 +233,7 @@ async function runVerb(command, client, target) {
205
233
  line('');
206
234
  return 2;
207
235
  }
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) {
236
+ if (!latest.run) {
214
237
  // Installed and never run is not the same as run and found nothing, and
215
238
  // saying "nothing got through" here would be the worst sentence in the
216
239
  // product printed about a night that never happened.
@@ -220,7 +243,7 @@ async function runVerb(command, client, target) {
220
243
  line('');
221
244
  return 0;
222
245
  }
223
- const run = rows[0];
246
+ const run = latest.run;
224
247
  line(' From the night of ' + new Date(run.ran_at).toISOString().slice(0, 16).replace('T', ' ') +
225
248
  ', on "' + run.source + '":');
226
249
  scanner.report({
@@ -583,6 +606,9 @@ async function main() {
583
606
  // think about it again, which is exactly the app that is unprotected
584
607
  // three months later. The tool already knows whether the nightly run is
585
608
  // there. So it asks.
609
+ // Whatever the nightly run last said is kept for the dashboard too, so
610
+ // the page shows both answers and says which is which.
611
+ await readAndKeepTheNight(client);
586
612
  if (!result.stopped) await offerTheNight(client, target, canAsk);
587
613
  } catch (err) {
588
614
  // Anything that went wrong mid-scan. The copy has already been dropped by
package/finding.js CHANGED
@@ -523,8 +523,32 @@ function serverOnlyLines(finding) {
523
523
  // column saying whose row it is. The assistant applying it had to correct
524
524
  // the prompt; a less careful one would have locked the table away from the
525
525
  // members who use it.
526
- const tenant = finding.authTied !== false && finding.owned === false &&
527
- (finding.columns || []).find((c) => TENANT_COLUMN.test(String(c)));
526
+ const team = finding.authTied !== false && (finding.columns || []).find((c) => TENANT_COLUMN.test(String(c)));
527
+ const maker = finding.owner || finding.ownedBy;
528
+ // A team's row that also says who made it - created_by beside workspace_id.
529
+ // Found on a benchmark: the prompt said to compare created_by to auth.uid()
530
+ // for reading, which hides every teammate's rows and breaks the shared
531
+ // view the table exists for. Who made a row decides who may write it
532
+ // under their name, not who may see it.
533
+ if (team && finding.owned !== false && maker) {
534
+ return [
535
+ 'Rows in "' + table + '" belong to a team ("' + team + '"), and "' + maker + '" only records who ' +
536
+ 'made each one. So reading should be decided by team membership: let a signed-in user read a ' +
537
+ 'row when they are a member of that row\'s "' + team + '" - checked through your membership ' +
538
+ 'table, or a helper function that asks it - and give logged-out visitors nothing. Do not ' +
539
+ 'compare "' + maker + '" to auth.uid() for reading: that hides teammates\' rows from each other ' +
540
+ 'and breaks the feature.',
541
+ '',
542
+ 'Write separate rules for reading, inserting, updating and deleting. Inserting: WITH CHECK that ' +
543
+ 'the user is a member of the row\'s "' + team + '" AND "' + maker + '" = auth.uid(), so nobody ' +
544
+ 'writes a row under somebody else\'s name or into a team they are not in. Updating and ' +
545
+ 'deleting: the membership check in USING (and WITH CHECK for updating), and decide whether any ' +
546
+ 'member may do it or only the row\'s "' + maker + '" or an admin. If only your server writes ' +
547
+ 'this table, add no write rules at all.',
548
+ '',
549
+ ];
550
+ }
551
+ const tenant = finding.owned === false && team;
528
552
  if (tenant) {
529
553
  return [
530
554
  'Rows in "' + table + '" belong to an organization ("' + tenant + '"), not to one person. So the ' +
@@ -563,6 +587,18 @@ function serverOnlyLines(finding) {
563
587
  ];
564
588
  }
565
589
 
590
+ /**
591
+ * The opening of a rules fix. "Switch row level security on" is said only
592
+ * when it is off: told it about a table where it was already on, the person
593
+ * looks for a switch that is already flipped and misses the rule that is the
594
+ * real problem.
595
+ */
596
+ function switchOn(finding) {
597
+ return finding.rlsEnabled
598
+ ? 'Row level security is already on for this table, so the rules on it are the problem: '
599
+ : 'Switch row level security on for this table, then ';
600
+ }
601
+
566
602
  /**
567
603
  * One prompt for everything wrong with one table's rules - read and write,
568
604
  * strangers and customers - because it is one rule to replace, not four.
@@ -597,9 +633,8 @@ function groupPromptFor(members) {
597
633
  '',
598
634
  ].concat(
599
635
  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.',
636
+ switchOn(lead) + 'remove the rule that lets everyone in, and write separate rules for reading, ' +
637
+ 'inserting, updating and deleting. A rule written FOR ALL covers far more than reading.',
603
638
  '',
604
639
  'Each of them should compare ' + ownerWords + ' to the id of the signed-in user (auth.uid()): ' +
605
640
  'USING for reading, updating and deleting, WITH CHECK for inserting and updating, so nobody ' +
@@ -706,9 +741,9 @@ function fixPromptFor(finding) {
706
741
  '',
707
742
  ].concat(
708
743
  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.',
744
+ switchOn(finding) + 'write separate rules for reading, inserting, updating and deleting. ' +
745
+ 'A rule written FOR SELECT does not cover writes, and a rule written FOR ALL covers ' +
746
+ 'far more than reading.',
712
747
  '',
713
748
  'For insert and update, use WITH CHECK comparing ' + owner +
714
749
  ' 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon-night",
3
- "version": "0.1.14",
3
+ "version": "0.1.19",
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 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,7 @@
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",
58
59
  "check:cli": "node cli.check.js",
59
60
  "check:writes": "node tamper.check.js",
60
61
  "check:orphans": "node orphans.check.js",
package/scan.js CHANGED
@@ -403,10 +403,14 @@ async function scan(client, sourceSchema, options) {
403
403
  const ties = finding.authTiesOf(plan);
404
404
  // And whether a row says whose it is: with no such column, no rule can
405
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))]));
406
+ // The column's name travels too: a team's table that also says who made
407
+ // each row needs it for its prompt, even on a finding that is not about
408
+ // owners at all.
409
+ const owned = new Map(theirs.map((table) => [table.name, attack.ownerColumn(table)]));
407
410
  const tagged = (f) => Object.assign({}, f,
408
411
  ties.has(f.table) ? { authTied: ties.get(f.table) } : {},
409
- owned.has(f.table) ? { owned: owned.get(f.table) } : {});
412
+ owned.has(f.table) ? { owned: Boolean(owned.get(f.table)) } : {},
413
+ owned.get(f.table) ? { ownedBy: owned.get(f.table) } : {});
410
414
 
411
415
  return {
412
416
  stopped: null,
@@ -438,6 +442,51 @@ function exitCodeFor(result) {
438
442
  return (result.findings || []).some((f) => f.status !== 'verification required') ? 1 : 0;
439
443
  }
440
444
 
445
+ /** Where the nightly run's last answer is kept for the dashboard. */
446
+ function nightlyFile() {
447
+ return store.open(process.cwd()).nightNightly;
448
+ }
449
+
450
+ /**
451
+ * The nightly run's last answer, in the shape the dashboard reads: the same
452
+ * described findings a scan saves, and the three states that are not the same
453
+ * as "nothing found" - not installed, installed and never run, and stopped -
454
+ * said as themselves. `latest` is installer.latestRun's answer.
455
+ */
456
+ function nightlyRecord(latest, readAt) {
457
+ const at = (value) => (value ? new Date(value).toISOString() : null);
458
+ if (!latest || !latest.installed) return { readAt: readAt, installed: false };
459
+ const where = latest.where || {};
460
+ const record = {
461
+ readAt: readAt,
462
+ installed: true,
463
+ scheduled: where.job ? where.job.schedule : null,
464
+ active: Boolean(where.job && where.job.active),
465
+ source: where.source || null,
466
+ ranAt: null,
467
+ };
468
+ const run = latest.run;
469
+ if (!run) return record;
470
+ return Object.assign(record, {
471
+ ranAt: at(run.ran_at),
472
+ source: run.source,
473
+ stopped: run.stopped || null,
474
+ attacksRun: run.attacks_run || 0,
475
+ notChecked: run.not_checked || [],
476
+ findings: run.stopped ? [] : finding.describeAll(run.findings || []),
477
+ });
478
+ }
479
+
480
+ /** Kept for the dashboard. A failure here never touches the run itself. */
481
+ function saveNightly(file, record) {
482
+ try {
483
+ fs.writeFileSync(file, JSON.stringify(record, null, 2));
484
+ return true;
485
+ } catch (err) {
486
+ return false;
487
+ }
488
+ }
489
+
441
490
  /** The last run, or null if there has not been one worth keeping. */
442
491
  function loadLastRun(file) {
443
492
  try {
@@ -506,6 +555,34 @@ function notTestedLines(result) {
506
555
  return lines;
507
556
  }
508
557
 
558
+ /**
559
+ * The warning above the verdict: how much went untested, counted honestly.
560
+ *
561
+ * Every entry used to be counted as a table. Found on a benchmark: one
562
+ * column's duplicate test did not run, and the top line said "1 table was
563
+ * NOT checked" about a table every other attack had covered - and a table
564
+ * with two attacks stuck was counted as two tables. An entry with no attack
565
+ * named is a whole table nothing could be put into; one with an attack named
566
+ * is part of a table.
567
+ */
568
+ function missedLines(notChecked) {
569
+ const tableOf = (entry) => String(entry.table).split('.')[0];
570
+ const whole = new Set(notChecked.filter((entry) => !entry.key).map(tableOf));
571
+ const parts = notChecked.filter((entry) => entry.key && !whole.has(tableOf(entry)));
572
+ const partly = new Set(parts.map(tableOf));
573
+ const tables = (n) => n + (n === 1 ? ' table' : ' tables');
574
+ const said = [];
575
+ if (whole.size) said.push(tables(whole.size) + (whole.size === 1 ? ' was' : ' were') + ' NOT checked');
576
+ if (parts.length) {
577
+ said.push(parts.length + (parts.length === 1 ? ' check' : ' checks') + ' on ' +
578
+ (whole.size ? tables(partly.size).replace(/ table/, ' other table') : tables(partly.size)) +
579
+ (parts.length === 1 ? ' was' : ' were') + ' NOT run');
580
+ }
581
+ const it = notChecked.length === 1 ? 'it' : 'them';
582
+ return wrap(said.join(', and ') + '. Whatever this report says next is not about ' + it + '. Treat ' +
583
+ it + ' as unknown, not as clear - the list, and why, is at the end.', 68).map((l) => ' ' + l);
584
+ }
585
+
509
586
  /** The report, as the person reads it in the morning. */
510
587
  function report(result) {
511
588
  if (result.stopped) {
@@ -530,11 +607,7 @@ function report(result) {
530
607
  const sayWhatWasMissed = () => {
531
608
  if (!notChecked.length) return;
532
609
  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.');
610
+ missedLines(notChecked).forEach(line);
538
611
  };
539
612
 
540
613
  if (!result.findings.length) {
@@ -702,9 +775,13 @@ if (require.main === module) {
702
775
  module.exports = {
703
776
  scan: scan,
704
777
  report: report,
778
+ missedLines: missedLines,
705
779
  wrap: wrap,
706
780
  loadLastRun: loadLastRun,
707
781
  saveRun: saveRun,
782
+ nightlyFile: nightlyFile,
783
+ nightlyRecord: nightlyRecord,
784
+ saveNightly: saveNightly,
708
785
  sweepOldCopies: sweepOldCopies,
709
786
  notTestedLines: notTestedLines,
710
787
  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
@@ -359,14 +359,19 @@ function explain(err, raw) {
359
359
  const ref = code === 'ENOTFOUND' ? supabaseDirectRef(raw) : null;
360
360
  if (ref) {
361
361
  return [
362
- 'I could not find that server - and I think I know why.',
362
+ 'I could not find that server. There are two likely reasons.',
363
363
  '',
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.',
364
+ 'Most likely: that address is Supabase’s direct connection, and it now',
365
+ 'answers only over IPv6. Plenty of home and office networks have no IPv6',
366
+ 'at all, and on those the name does not resolve to anything.',
368
367
  '',
369
- 'There is nothing wrong with your project or your password. Use the',
368
+ // A ref with one letter wrong fails exactly the same way, and blaming
369
+ // only IPv6 would send somebody to change networks over a typo.
370
+ 'Or the address is mistyped. Check that "' + ref + '" matches the project',
371
+ 'ref in Supabase -> Project Settings -> General; one wrong letter fails',
372
+ 'exactly the same way.',
373
+ '',
374
+ 'Your password has not been tried yet, so it is not the problem. Use the',
370
375
  'session pooler instead, which answers over the ordinary internet:',
371
376
  '',
372
377
  ' Supabase dashboard -> Project Settings -> Database ->',