kryptheon-night 0.1.8 → 0.1.9

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.
@@ -561,12 +561,15 @@ async function main() {
561
561
  if (!before) {
562
562
  scanner.report(result);
563
563
  scanner.saveRun(file, result);
564
- process.exitCode = result.stopped ? 2 : result.findings.length ? 1 : 0;
564
+ process.exitCode = scanner.exitCodeFor(result);
565
565
  } else {
566
566
  const verdict = recheck.compare(before, result);
567
567
  recheck.describe(verdict).forEach(line);
568
568
  recheck.badgeLines(verdict, result.attacksRun || 0).forEach(line);
569
+ // The report ends with what was not tested; with nothing left to report
570
+ // that list still has to be printed, or the re-check goes quiet about it.
569
571
  if (result.findings.length) scanner.report(result);
572
+ else scanner.notTestedLines(result).forEach(line);
570
573
  scanner.saveRun(file, result);
571
574
  process.exitCode = result.stopped ? 2 : verdict.allClear ? 0 : 1;
572
575
  }
package/finding.js CHANGED
@@ -72,6 +72,14 @@ function listOf(items) {
72
72
  * should be fixed - there is no such thing as a low finding here.
73
73
  */
74
74
  function severityOf(finding, contents) {
75
+ if (finding.kind === 'privileged') {
76
+ // Not proven, so never CRITICAL: this is a reach that exists, not a break
77
+ // that was demonstrated. A function anon can call that also writes is worth
78
+ // more attention than one that only reads, but both stay HIGH and both
79
+ // carry "verification required" - the person has to say whether the anon
80
+ // call was intended.
81
+ return 'HIGH';
82
+ }
75
83
  if (finding.kind === 'orphaned') {
76
84
  // Nothing is exposed and nothing is destroyed, so this is not the same
77
85
  // order of thing as a table anyone can empty. It stays serious because
@@ -90,7 +98,13 @@ function severityOf(finding, contents) {
90
98
  // the table. A second row holding the same session token is critical even
91
99
  // if the table has nothing personal in it at all.
92
100
  if (finding.expectation === 'credential') return 'CRITICAL';
93
- if (finding.expectation === 'identity') return 'CRITICAL';
101
+ // Critical only while this column could be the login itself. When the
102
+ // database has Supabase's auth.users, sign-in goes through that table,
103
+ // which keeps every email unique - so a duplicate here is a copy that
104
+ // disagrees with itself, not two accounts sharing one login. Found on a
105
+ // real app: the report said "two accounts can answer to the same login"
106
+ // about a profiles table the login never read.
107
+ if (finding.expectation === 'identity') return finding.loginElsewhere ? 'HIGH' : 'CRITICAL';
94
108
  return 'HIGH';
95
109
  }
96
110
  if (contents.secrets.length) return 'CRITICAL';
@@ -247,6 +261,9 @@ function headlineFor(finding) {
247
261
  if (finding.kind === 'duplicated') {
248
262
  return 'Your ' + finding.table + ' table lets the same ' + finding.column + ' exist twice.';
249
263
  }
264
+ if (finding.kind === 'privileged') {
265
+ return 'Anyone on the internet can call your ' + finding.fn + ' function, and it runs with full rights.';
266
+ }
250
267
  if (finding.kind === 'exposed') {
251
268
  return 'Your ' + finding.table + ' ' + (finding.isView ? 'view' : 'table') + ' can be read by anyone.';
252
269
  }
@@ -263,6 +280,17 @@ const COST_OF_A_DUPLICATE = {
263
280
  code: 'A one-time code that can exist twice can be redeemed twice.',
264
281
  };
265
282
 
283
+ // Said instead of the login sentence when sign-in happens in auth.users.
284
+ const COST_OF_A_COPIED_IDENTITY =
285
+ 'Signing in is not affected: your app signs people in through auth.users, ' +
286
+ 'which keeps each email unique. But anything that looks a person up by this ' +
287
+ 'column can get two rows back and pick the wrong one.';
288
+
289
+ function costOf(finding) {
290
+ if (finding.expectation === 'identity' && finding.loginElsewhere) return COST_OF_A_COPIED_IDENTITY;
291
+ return COST_OF_A_DUPLICATE[finding.expectation] || '';
292
+ }
293
+
266
294
  function bodyFor(finding, contents) {
267
295
  const holds = listOf(contents.secrets.concat(contents.identity, contents.money));
268
296
  const rowWord = finding.readable === 1 ? 'row' : 'rows';
@@ -300,14 +328,32 @@ function bodyFor(finding, contents) {
300
328
  'kept, and your live app was never touched.';
301
329
  }
302
330
 
331
+ if (finding.kind === 'privileged') {
332
+ // Said as what is true, not as what was done - nothing was executed. The
333
+ // reach is real; whether it is a mistake is the person's to say, so the
334
+ // wording asks rather than accuses.
335
+ return (
336
+ 'This function is SECURITY DEFINER, so it runs as whoever created it, not ' +
337
+ 'as the visitor calling it - which means the row level security on the tables ' +
338
+ 'it touches is checked against the owner and skipped for the caller. It is ' +
339
+ 'granted to anon, the role a visitor with no account uses, so anyone on the ' +
340
+ 'internet can call it' +
341
+ (finding.writes ? ' - and it writes to your data, so a caller can change rows the rules would otherwise protect.'
342
+ : ', reaching whatever it reads past the rules on those tables.') +
343
+ ' I did not call it: whether an open function like this is intended is ' +
344
+ 'something only you can confirm.' +
345
+ (finding.hasFixedSearchPath ? ''
346
+ : ' It also does not pin its search_path, so a caller can point the names ' +
347
+ 'inside it at their own objects.')
348
+ );
349
+ }
303
350
  if (finding.kind === 'duplicated') {
304
351
  // Said as what was done, not as what it implies. Two connections, one
305
352
  // moment, both accepted, and here is the count afterwards.
306
353
  return (
307
354
  'I opened two connections to a copy of your app and inserted the same ' +
308
355
  finding.column + ' from both at the same moment. Both were accepted, so there are ' +
309
- 'now ' + finding.copies + ' rows holding the identical value. ' +
310
- (COST_OF_A_DUPLICATE[finding.expectation] || '')
356
+ 'now ' + finding.copies + ' rows holding the identical value. ' + costOf(finding)
311
357
  ).trim();
312
358
  }
313
359
 
@@ -368,7 +414,36 @@ function fixPromptFor(finding) {
368
414
  const cause = causeOf(finding);
369
415
  const owner = finding.owner ? '"' + finding.owner + '"' : 'the column that says who each row belongs to';
370
416
 
371
- const lines = finding.kind === 'orphaned'
417
+ const lines = finding.kind === 'privileged'
418
+ ? [
419
+ 'My app may have a security problem - please check it rather than assume it.',
420
+ '',
421
+ 'The database function "' + finding.fn + '(' + (finding.args || '') + ')" is SECURITY ' +
422
+ 'DEFINER, so it runs with its owner\'s rights and ignores row level security. It is ' +
423
+ 'granted EXECUTE to "anon", so anyone who is not logged in can call it' +
424
+ (finding.writes ? ', and it writes to tables.' : '.'),
425
+ '',
426
+ 'First tell me: is it meant to be callable by logged-out visitors? If it is not, ' +
427
+ 'REVOKE EXECUTE ON FUNCTION "' + finding.fn + '"(' + (finding.args || '') + ') FROM anon, PUBLIC, ' +
428
+ 'and GRANT EXECUTE only to the role that should run it - usually service_role, the ' +
429
+ 'backend key, the way an admin-only function like this should be reached.',
430
+ '',
431
+ ].concat(
432
+ finding.hasFixedSearchPath
433
+ ? []
434
+ : [
435
+ 'It also does not set a fixed search_path. Add SET search_path = pg_catalog, public (or ' +
436
+ 'the schemas it truly needs) so a caller cannot make it resolve names to their own objects.',
437
+ '',
438
+ ],
439
+ [
440
+ 'If it is meant to be public, keep it, but make sure everything inside it checks what the ' +
441
+ 'caller is allowed to see or change itself - the table rules will not do it here.',
442
+ '',
443
+ 'Then look at every other SECURITY DEFINER function for the same grant.',
444
+ ],
445
+ )
446
+ : finding.kind === 'orphaned'
372
447
  ? [
373
448
  'My app has a data problem.',
374
449
  '',
@@ -418,17 +493,50 @@ function fixPromptFor(finding) {
418
493
  '". I proved it by inserting the same value from two connections at the same ' +
419
494
  'moment, and both were accepted.',
420
495
  '',
421
- 'Add a unique constraint on "' + finding.table + '"."' + finding.column +
422
- '" in the database itself. Checking in application code before inserting is not ' +
423
- 'enough - between the check and the insert, the other request has already gone in.',
496
+ // First, because it has to happen before anything is changed. Learned
497
+ // by applying the short version of this prompt literally to a real app:
498
+ // the constraint cannot be created while duplicates exist, and an
499
+ // assistant told only "add a constraint" may delete rows to get there.
500
+ 'First, find the rows that already share a value and show them to me. Do not ' +
501
+ 'delete or merge any of them without asking me: the rule below cannot be ' +
502
+ 'created while they exist, and deleting rows to make it pass loses real data.',
503
+ '',
504
+ (finding.expectation === 'identity'
505
+ ? 'Then add a unique index on lower("' + finding.column + '"), so that "' + finding.table +
506
+ '"."' + finding.column + '" is unique whatever the case - Ann@example.com and ' +
507
+ 'ann@example.com are the same person.'
508
+ : 'Then add a unique constraint on "' + finding.table + '"."' + finding.column + '".') +
509
+ ' It has to be in the database itself. Checking in application code before inserting ' +
510
+ 'is not enough - between the check and the insert, the other request has already gone in.',
424
511
  '',
425
512
  // Said because the obvious fix is wrong for multi-tenant apps, and being
426
513
  // told to drop a legitimate design would cost them more than the bug.
427
- 'If the same value is allowed to repeat for different owners, make the constraint ' +
428
- 'cover both columns together rather than leaving it off.',
514
+ 'If the same value is allowed to repeat for different owners, make it cover both ' +
515
+ 'columns together rather than leaving it off.',
429
516
  '',
430
- 'Then look for the same missing constraint on every other table and fix those too.',
431
- ]
517
+ ].concat(
518
+ [
519
+ 'Find everything that writes this column - app code, and any database trigger or ' +
520
+ 'function, such as one that copies each new sign-up into this table - and make ' +
521
+ 'sure each one handles "this value already exists" instead of failing. If people ' +
522
+ 'can edit this column themselves, decide whether they should: once it is unique, ' +
523
+ 'someone who puts another person\'s value in first will block that person.',
524
+ '',
525
+ ],
526
+ finding.expectation === 'identity' && finding.loginElsewhere
527
+ ? [
528
+ 'Sign-in uses auth.users, so this column is only a copy of the email there. ' +
529
+ 'Consider not letting people edit it at all and keeping it in step with auth.users.',
530
+ '',
531
+ ]
532
+ : [],
533
+ [
534
+ 'Afterwards, sign up a brand-new account and edit an existing profile to make sure ' +
535
+ 'both still work, and run this check again.',
536
+ '',
537
+ 'Then look for the same missing constraint on every other table and fix those too.',
538
+ ],
539
+ )
432
540
  : finding.isView
433
541
  ? [
434
542
  'My app has a security problem.',
@@ -471,7 +579,15 @@ function describe(finding) {
471
579
  const cause = causeOf(finding);
472
580
  return {
473
581
  severity: severityOf(finding, contents),
474
- table: finding.table,
582
+ // Every other finding here was proven by an attack that ran; this is the
583
+ // one the tool reasons about without executing, so it says so. The report
584
+ // and the re-check both read this: a "verification required" finding is
585
+ // never counted towards a clean re-check on its own.
586
+ status: finding.kind === 'privileged' ? 'verification required' : 'confirmed',
587
+ table: finding.kind === 'privileged' ? finding.fn : finding.table,
588
+ fn: finding.fn,
589
+ args: finding.args,
590
+ writes: finding.writes,
475
591
  kind: finding.kind,
476
592
  // Carried through because a table can have two different columns that each
477
593
  // accept a duplicate, and the re-check tells one finding from another by
@@ -483,10 +599,15 @@ function describe(finding) {
483
599
  expectation: finding.expectation,
484
600
  headline: headlineFor(finding, contents),
485
601
  body: bodyFor(finding, contents),
486
- cause: cause.long,
602
+ // For a privileged function there is no "why the door is open" the way a
603
+ // table has one; the body already carries the whole explanation, so the
604
+ // cause line would only repeat it. Left empty and skipped by the report.
605
+ cause: finding.kind === 'privileged' ? '' : cause.long,
487
606
  who: finding.who,
488
607
  can: finding.can,
489
- proof: finding.kind === 'duplicated'
608
+ proof: finding.kind === 'privileged'
609
+ ? 'I did not call "' + finding.fn + '". This is a reach that exists in the grants, not a break I ran.'
610
+ : finding.kind === 'duplicated'
490
611
  ? 'I created ' + finding.copies + ' rows in "' + finding.table + '" holding the same ' +
491
612
  finding.column + '.'
492
613
  : finding.countOnly
@@ -524,7 +645,7 @@ function describeAll(findings) {
524
645
  // ones that need an account, and those before the ones that need two requests
525
646
  // to arrive together.
526
647
  // Writes above reads: a table somebody emptied is worse than one they read.
527
- const byKind = { writable: 0, exposed: 1, crossed: 2, duplicated: 3, orphaned: 4 };
648
+ const byKind = { writable: 0, exposed: 1, crossed: 2, duplicated: 3, orphaned: 4, privileged: 5 };
528
649
  const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
529
650
  return described.sort((a, b) => rank(a) - rank(b));
530
651
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon-night",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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
@@ -116,6 +116,9 @@ function compare(before, after) {
116
116
  stillOpen: stillOpen,
117
117
  unverifiable: unverifiable,
118
118
  newlyBroken: newlyBroken,
119
+ // Carried so the re-check can say why there is no green light when the
120
+ // only thing standing in the way is what could not be tested.
121
+ untested: skipped,
119
122
  // Green is the strictest thing this program says, so it is the hardest to
120
123
  // earn: everything that was wrong is now provably right, nothing new broke,
121
124
  // and nothing at all was left untested.
@@ -208,6 +211,18 @@ function describe(result) {
208
211
  lines.push('');
209
212
  }
210
213
 
214
+ // Found on a real app: "1 problem is fixed", nothing else, and a non-zero
215
+ // exit with no word about why. The reason was parts of the app that could
216
+ // not be tested - which has to be said, or the exit code reads as a bug.
217
+ const untested = result.untested || [];
218
+ if (untested.length) {
219
+ lines.push(' No green light yet: ' + untested.length + ' ' +
220
+ (untested.length === 1 ? 'part of your app was' : 'parts of your app were') +
221
+ ' not tested this time, so I cannot');
222
+ lines.push(' call it clear. They are listed at the end, with the reason for each.');
223
+ lines.push('');
224
+ }
225
+
211
226
  return lines;
212
227
  }
213
228
 
package/scan.js CHANGED
@@ -344,13 +344,41 @@ 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
+
347
375
  return {
348
376
  stopped: null,
349
377
  attacksRun: attempted.length,
350
378
  notChecked: notChecked,
351
379
  attempted: attempted,
352
380
  findings: finding.describeAll(
353
- impersonation.findings.concat(writes.findings, stranded.findings, collisions.findings),
381
+ impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged),
354
382
  ),
355
383
  };
356
384
  } finally {
@@ -363,6 +391,17 @@ async function scan(client, sourceSchema, options) {
363
391
  }
364
392
  }
365
393
 
394
+ /**
395
+ * The exit code of a first run: 2 could not run, 1 something got through, 0
396
+ * every attack lost. Only confirmed findings count as "got through" - a
397
+ * function flagged for verification was never executed, and a script reading
398
+ * 1 would act on a break nobody demonstrated.
399
+ */
400
+ function exitCodeFor(result) {
401
+ if (result.stopped) return 2;
402
+ return (result.findings || []).some((f) => f.status !== 'verification required') ? 1 : 0;
403
+ }
404
+
366
405
  /** The last run, or null if there has not been one worth keeping. */
367
406
  function loadLastRun(file) {
368
407
  try {
@@ -477,18 +516,29 @@ function report(result) {
477
516
 
478
517
  sayWhatWasMissed();
479
518
 
480
- const count = result.findings.length;
519
+ // Confirmed breaks and things that only need a look are not the same claim,
520
+ // so they are counted apart. Saying "3 problems" when one of them was never
521
+ // executed is the kind of overstatement that costs the next report its trust.
522
+ const confirmed = result.findings.filter((f) => f.status !== 'verification required');
523
+ const toVerify = result.findings.filter((f) => f.status === 'verification required');
481
524
  line('');
482
- line(' ' + count + (count === 1 ? ' problem' : ' problems') + ' found.');
525
+ if (confirmed.length) {
526
+ line(' ' + confirmed.length + (confirmed.length === 1 ? ' problem' : ' problems') + ' found.');
527
+ }
528
+ if (toVerify.length) {
529
+ line(' ' + toVerify.length + ' thing' + (toVerify.length === 1 ? '' : 's') +
530
+ ' to check - I did not attack ' + (toVerify.length === 1 ? 'it' : 'them') + ', only spotted the risk.');
531
+ }
483
532
 
484
533
  for (const item of result.findings) {
485
534
  line('');
486
535
  line(' ' + '-'.repeat(68));
487
- line(' ' + item.severity + ' ' + item.table);
536
+ line(' ' + item.severity + ' ' + item.table +
537
+ (item.status === 'verification required' ? ' (verification required)' : ''));
488
538
  line('');
489
539
  line(' ' + item.headline);
490
540
  line('');
491
- for (const paragraph of [item.body, item.cause]) {
541
+ for (const paragraph of [item.body, item.cause].filter(Boolean)) {
492
542
  wrap(paragraph, 70).forEach((l) => line(' ' + l));
493
543
  line('');
494
544
  }
@@ -587,7 +637,7 @@ async function main() {
587
637
  // Three answers, three codes: 0 clean, 1 problems found, 2 could not
588
638
  // run. A scan that stopped used to exit 0 with no findings, which is
589
639
  // what any script watching it would read as a pass.
590
- process.exitCode = result.stopped ? 2 : result.findings.length ? 1 : 0;
640
+ process.exitCode = exitCodeFor(result);
591
641
  return;
592
642
  }
593
643
 
@@ -596,6 +646,7 @@ async function main() {
596
646
  recheck.badgeLines(verdict, result.attacksRun || 0).forEach(line);
597
647
  // The verdict says what changed; this says what to do about what did not.
598
648
  if (result.findings.length) report(result);
649
+ else notTestedLines(result).forEach(line);
599
650
  saveRun(file, result);
600
651
  process.exitCode = result.stopped ? 2 : verdict.allClear ? 0 : 1;
601
652
  } finally {
@@ -621,5 +672,6 @@ module.exports = {
621
672
  sweepOldCopies: sweepOldCopies,
622
673
  notTestedLines: notTestedLines,
623
674
  lastRunFile: lastRunFile,
675
+ exitCodeFor: exitCodeFor,
624
676
  ABANDONED_AFTER: ABANDONED_AFTER,
625
677
  };