kryptheon-night 0.1.8 → 0.1.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon-night",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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 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 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",
@@ -52,6 +52,8 @@
52
52
  "check:shapes": "node shapes.check.js",
53
53
  "check:verdicts": "node verdicts.check.js",
54
54
  "check:shop": "node shop.check.js",
55
+ "check:privileged": "node privileged.check.js",
56
+ "check:cli": "node cli.check.js",
55
57
  "check:writes": "node tamper.check.js",
56
58
  "check:orphans": "node orphans.check.js",
57
59
  "probe:write": "node probe-write.js",
package/recheck.js CHANGED
@@ -42,9 +42,20 @@ function keyOf(item) {
42
42
  * untestable table would land in `fixed`, and a green badge would be handed to
43
43
  * an app nobody checked.
44
44
  */
45
+ /**
46
+ * A report shows one finding per table, but each one keeps the proofs it was
47
+ * made of. Those are what get compared: closing a table to strangers' writes
48
+ * while it stays readable is one proof fixed and one still open, not "the
49
+ * table's finding" either fixed or not. Reports saved before findings were
50
+ * grouped have no members, and are read as they are.
51
+ */
52
+ function proofsOf(list) {
53
+ return (list || []).reduce((all, item) => all.concat(item.members && item.members.length ? item.members : [item]), []);
54
+ }
55
+
45
56
  function compare(before, after) {
46
- const was = (before && before.findings) || [];
47
- const now = (after && after.findings) || [];
57
+ const was = proofsOf(before && before.findings);
58
+ const now = proofsOf(after && after.findings);
48
59
 
49
60
  // The second run failed outright. Nothing can be claimed about anything.
50
61
  if (after && after.stopped) {
@@ -116,6 +127,9 @@ function compare(before, after) {
116
127
  stillOpen: stillOpen,
117
128
  unverifiable: unverifiable,
118
129
  newlyBroken: newlyBroken,
130
+ // Carried so the re-check can say why there is no green light when the
131
+ // only thing standing in the way is what could not be tested.
132
+ untested: skipped,
119
133
  // Green is the strictest thing this program says, so it is the hardest to
120
134
  // earn: everything that was wrong is now provably right, nothing new broke,
121
135
  // and nothing at all was left untested.
@@ -146,6 +160,21 @@ function badgeLines(result, attacksRun) {
146
160
  ];
147
161
  }
148
162
 
163
+ /**
164
+ * Which proof a line is about. One table can have several - strangers reading
165
+ * it, strangers writing to it - and "waitlist, waitlist" under "fixed" would
166
+ * not say which one was closed.
167
+ */
168
+ function whatOf(item) {
169
+ if (item.kind === 'exposed') return 'read by anyone';
170
+ if (item.kind === 'crossed') return "customers reading each other's rows";
171
+ if (item.kind === 'writable') return item.who === 'anyone' ? 'written by anyone' : 'written by any signed-in customer';
172
+ if (item.kind === 'duplicated') return 'the same ' + (item.column || 'value') + ' twice';
173
+ if (item.kind === 'orphaned') return 'rows left pointing at nothing';
174
+ if (item.kind === 'privileged') return 'a function anyone can call';
175
+ return item.kind;
176
+ }
177
+
149
178
  /** What the re-check says, in the order it matters. */
150
179
  function describe(result) {
151
180
  const lines = [''];
@@ -162,7 +191,7 @@ function describe(result) {
162
191
  lines.push(' ' + result.fixed.length + (result.fixed.length === 1 ? ' problem is' : ' problems are') + ' fixed:');
163
192
  lines.push('');
164
193
  for (const item of result.fixed) {
165
- lines.push(' ' + item.table + ' - I ran the same attack again and it was refused.');
194
+ lines.push(' ' + item.table + ' (' + whatOf(item) + ') - I ran the same attack again and it was refused.');
166
195
  }
167
196
  lines.push('');
168
197
  }
@@ -182,7 +211,7 @@ function describe(result) {
182
211
  lines.push(' ' + result.unverifiable.length + ' I could NOT confirm:');
183
212
  lines.push('');
184
213
  for (const item of result.unverifiable) {
185
- lines.push(' ' + item.table + ' - ' + item.why);
214
+ lines.push(' ' + item.table + ' (' + whatOf(item) + ') - ' + item.why);
186
215
  }
187
216
  lines.push('');
188
217
  lines.push(' These are not fixed and not broken - they are unknown. The problem');
@@ -208,6 +237,18 @@ function describe(result) {
208
237
  lines.push('');
209
238
  }
210
239
 
240
+ // Found on a real app: "1 problem is fixed", nothing else, and a non-zero
241
+ // exit with no word about why. The reason was parts of the app that could
242
+ // not be tested - which has to be said, or the exit code reads as a bug.
243
+ const untested = result.untested || [];
244
+ if (untested.length) {
245
+ lines.push(' No green light yet: ' + untested.length + ' ' +
246
+ (untested.length === 1 ? 'part of your app was' : 'parts of your app were') +
247
+ ' not tested this time, so I cannot');
248
+ lines.push(' call it clear. They are listed at the end, with the reason for each.');
249
+ lines.push('');
250
+ }
251
+
211
252
  return lines;
212
253
  }
213
254
 
package/scan.js CHANGED
@@ -344,13 +344,52 @@ async function scan(client, sourceSchema, options) {
344
344
  }
345
345
  }
346
346
 
347
+ // Where people actually sign in decides how bad a duplicate email is. On
348
+ // Supabase that is auth.users, which keeps emails unique; a public table's
349
+ // email column is then a copy. Only read, never written.
350
+ const loginElsewhere = (await client.query("select to_regclass('auth.users') is not null as found")).rows[0].found;
351
+ const raced = collisions.findings.map((f) =>
352
+ f.expectation === 'identity' ? Object.assign({}, f, { loginElsewhere: loginElsewhere }) : f,
353
+ );
354
+
355
+ // Functions an anonymous visitor can call that run past the rules. Read
356
+ // from the shape, not attacked: nothing is executed, because whether such
357
+ // a function is meant to be public is a question only the owner answers.
358
+ //
359
+ // Every definer function read this run counts as examined, whether or not
360
+ // anon can call it. That is what lets the re-check call a revoked grant
361
+ // fixed: the function was looked at again and the reach was gone. The first
362
+ // version recorded only the flagged ones, so a revoke made the function
363
+ // vanish from both lists and the fix came back as "could not confirm".
364
+ for (const fn of plan.anonFunctions || []) attempted.push('privileged:' + fn.name);
365
+ const privileged = (plan.anonFunctions || []).filter((fn) => fn.callable).map((fn) => ({
366
+ kind: 'privileged',
367
+ fn: fn.name,
368
+ table: fn.name,
369
+ args: fn.args,
370
+ writes: fn.writes,
371
+ hasFixedSearchPath: fn.hasFixedSearchPath,
372
+ columns: [],
373
+ }));
374
+
375
+ // Whether each table is tied to Supabase's sign-in decides what its fix
376
+ // can be. Read from the original schema: the copy points auth.users keys
377
+ // at a stand-in, and would call every table untied.
378
+ const ties = finding.authTiesOf(plan);
379
+ // And whether a row says whose it is: with no such column, no rule can
380
+ // tie it to the person asking. Read from the copy, the way seeding did.
381
+ const owned = new Map(theirs.map((table) => [table.name, Boolean(attack.ownerColumn(table))]));
382
+ const tagged = (f) => Object.assign({}, f,
383
+ ties.has(f.table) ? { authTied: ties.get(f.table) } : {},
384
+ owned.has(f.table) ? { owned: owned.get(f.table) } : {});
385
+
347
386
  return {
348
387
  stopped: null,
349
388
  attacksRun: attempted.length,
350
389
  notChecked: notChecked,
351
390
  attempted: attempted,
352
391
  findings: finding.describeAll(
353
- impersonation.findings.concat(writes.findings, stranded.findings, collisions.findings),
392
+ impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged).map(tagged),
354
393
  ),
355
394
  };
356
395
  } finally {
@@ -363,6 +402,17 @@ async function scan(client, sourceSchema, options) {
363
402
  }
364
403
  }
365
404
 
405
+ /**
406
+ * The exit code of a first run: 2 could not run, 1 something got through, 0
407
+ * every attack lost. Only confirmed findings count as "got through" - a
408
+ * function flagged for verification was never executed, and a script reading
409
+ * 1 would act on a break nobody demonstrated.
410
+ */
411
+ function exitCodeFor(result) {
412
+ if (result.stopped) return 2;
413
+ return (result.findings || []).some((f) => f.status !== 'verification required') ? 1 : 0;
414
+ }
415
+
366
416
  /** The last run, or null if there has not been one worth keeping. */
367
417
  function loadLastRun(file) {
368
418
  try {
@@ -477,18 +527,29 @@ function report(result) {
477
527
 
478
528
  sayWhatWasMissed();
479
529
 
480
- const count = result.findings.length;
530
+ // Confirmed breaks and things that only need a look are not the same claim,
531
+ // so they are counted apart. Saying "3 problems" when one of them was never
532
+ // executed is the kind of overstatement that costs the next report its trust.
533
+ const confirmed = result.findings.filter((f) => f.status !== 'verification required');
534
+ const toVerify = result.findings.filter((f) => f.status === 'verification required');
481
535
  line('');
482
- line(' ' + count + (count === 1 ? ' problem' : ' problems') + ' found.');
536
+ if (confirmed.length) {
537
+ line(' ' + confirmed.length + (confirmed.length === 1 ? ' problem' : ' problems') + ' found.');
538
+ }
539
+ if (toVerify.length) {
540
+ line(' ' + toVerify.length + ' thing' + (toVerify.length === 1 ? '' : 's') +
541
+ ' to check - I did not attack ' + (toVerify.length === 1 ? 'it' : 'them') + ', only spotted the risk.');
542
+ }
483
543
 
484
544
  for (const item of result.findings) {
485
545
  line('');
486
546
  line(' ' + '-'.repeat(68));
487
- line(' ' + item.severity + ' ' + item.table);
547
+ line(' ' + item.severity + ' ' + item.table +
548
+ (item.status === 'verification required' ? ' (verification required)' : ''));
488
549
  line('');
489
550
  line(' ' + item.headline);
490
551
  line('');
491
- for (const paragraph of [item.body, item.cause]) {
552
+ for (const paragraph of [item.body, item.cause].filter(Boolean)) {
492
553
  wrap(paragraph, 70).forEach((l) => line(' ' + l));
493
554
  line('');
494
555
  }
@@ -587,7 +648,7 @@ async function main() {
587
648
  // Three answers, three codes: 0 clean, 1 problems found, 2 could not
588
649
  // run. A scan that stopped used to exit 0 with no findings, which is
589
650
  // what any script watching it would read as a pass.
590
- process.exitCode = result.stopped ? 2 : result.findings.length ? 1 : 0;
651
+ process.exitCode = exitCodeFor(result);
591
652
  return;
592
653
  }
593
654
 
@@ -596,6 +657,7 @@ async function main() {
596
657
  recheck.badgeLines(verdict, result.attacksRun || 0).forEach(line);
597
658
  // The verdict says what changed; this says what to do about what did not.
598
659
  if (result.findings.length) report(result);
660
+ else notTestedLines(result).forEach(line);
599
661
  saveRun(file, result);
600
662
  process.exitCode = result.stopped ? 2 : verdict.allClear ? 0 : 1;
601
663
  } finally {
@@ -621,5 +683,6 @@ module.exports = {
621
683
  sweepOldCopies: sweepOldCopies,
622
684
  notTestedLines: notTestedLines,
623
685
  lastRunFile: lastRunFile,
686
+ exitCodeFor: exitCodeFor,
624
687
  ABANDONED_AFTER: ABANDONED_AFTER,
625
688
  };