kryptheon-night 0.1.10 → 0.1.14

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/attack.js CHANGED
@@ -502,6 +502,22 @@ function refusalMeans(message) {
502
502
  return denied.test(String(message)) ? 'unreachable' : 'untested';
503
503
  }
504
504
 
505
+ /**
506
+ * The table whose rule looks itself up, or null.
507
+ *
508
+ * A rule on organization_members that asks organization_members who is a
509
+ * member has to be checked to be checked, and Postgres stops the request
510
+ * rather than loop. Found on a blind test: every signed-in read touching that
511
+ * table failed, the app was broken for everyone with an account, and the
512
+ * report listed it only as a reason something was not tested. It is a finding
513
+ * in its own right - and it names the table at fault, which is often not the
514
+ * one that was being read.
515
+ */
516
+ function recursionIn(message) {
517
+ const found = /infinite recursion detected in policy for relation "([^"]+)"/i.exec(String(message || ''));
518
+ return found ? found[1] : null;
519
+ }
520
+
505
521
  /**
506
522
  * What each table gives away, and to whom.
507
523
  *
@@ -517,6 +533,8 @@ async function impersonate(client, schema, tables) {
517
533
  const findings = [];
518
534
  const completed = [];
519
535
  const blocked = [];
536
+ // Rules that looked themselves up, and which read ran into each one.
537
+ const looped = [];
520
538
 
521
539
  /** Did this read produce a verdict, and if not, why not? */
522
540
  const settle = (key, table, answer, as) => {
@@ -539,6 +557,19 @@ async function impersonate(client, schema, tables) {
539
557
  const anon = await readAs(client, schema, table.name, 'anon', null);
540
558
  const asA = await readAs(client, schema, table.name, 'authenticated', USER_A);
541
559
 
560
+ // Looked for on every table, owner or not: a signed-in read of a table
561
+ // with nobody's name on it fails just the same, and it is the same
562
+ // broken page.
563
+ for (const [answer, who] of [[anon, 'anyone'], [asA, 'signed-in']]) {
564
+ const relation = !Array.isArray(answer) && recursionIn(answer.blocked);
565
+ if (relation) looped.push({ relation: relation, table: table.name, who: who });
566
+ }
567
+ // Only a read of the table itself that came back with a verdict, for both
568
+ // callers, shows its rules no longer loop. A read that failed for some
569
+ // other reason shows nothing either way.
570
+ const answered = (answer) => Array.isArray(answer) || refusalMeans(answer.blocked) === 'unreachable';
571
+ if (answered(anon) && answered(asA)) completed.push('recursive:' + table.name);
572
+
542
573
  if (settle('exposed:' + table.name, table.name, anon, 'as a logged-out visitor') && anon.length > 0) {
543
574
  findings.push({
544
575
  kind: 'exposed',
@@ -567,7 +598,7 @@ async function impersonate(client, schema, tables) {
567
598
  }
568
599
  }
569
600
 
570
- return { findings: findings, completed: completed, blocked: blocked };
601
+ return { findings: findings, completed: completed, blocked: blocked, looped: looped };
571
602
  }
572
603
 
573
604
  /** A stable shape for comparing one run against another. */
@@ -585,6 +616,7 @@ module.exports = {
585
616
  USER_C: USER_C,
586
617
  USER_D: USER_D,
587
618
  refusalMeans: refusalMeans,
619
+ recursionIn: recursionIn,
588
620
  ownerColumn: ownerColumn,
589
621
  fitTo: fitTo,
590
622
  foreignKeys: foreignKeys,
package/engine.sql CHANGED
@@ -1613,6 +1613,17 @@ RETURNS text LANGUAGE sql IMMUTABLE AS $$
1613
1613
  THEN 'unreachable' ELSE 'untested' END;
1614
1614
  $$;
1615
1615
 
1616
+ /*
1617
+ * The table whose rule looks itself up, or null. The twin of recursionIn in
1618
+ * attack.js: Postgres stops such a request rather than loop, and names the
1619
+ * table at fault - often not the one that was being read.
1620
+ */
1621
+ CREATE OR REPLACE FUNCTION __KN__.recursion_in(message text)
1622
+ RETURNS text LANGUAGE sql IMMUTABLE AS $$
1623
+ SELECT (regexp_match(coalesce(message, ''),
1624
+ 'infinite recursion detected in policy for relation "([^"]+)"', 'i'))[1];
1625
+ $$;
1626
+
1616
1627
  /*
1617
1628
  * Reads a table the way a request would, as whoever is asking.
1618
1629
  *
@@ -1697,6 +1708,8 @@ DECLARE
1697
1708
  findings jsonb := '[]'::jsonb;
1698
1709
  completed jsonb := '[]'::jsonb;
1699
1710
  blocked jsonb := '[]'::jsonb;
1711
+ -- Rules that looked themselves up, and which read ran into each one.
1712
+ looped jsonb := '[]'::jsonb;
1700
1713
  tab jsonb;
1701
1714
  owner text;
1702
1715
  anon jsonb;
@@ -1713,6 +1726,22 @@ BEGIN
1713
1726
  anon := __KN__.read_as(source, tab->>'name', 'anon', NULL, NULL);
1714
1727
  as_a := __KN__.read_as(source, tab->>'name', 'authenticated', __KN__.user_a(), owner);
1715
1728
 
1729
+ -- Looked for on every table, owner or not. Only a read of the table itself
1730
+ -- that came back with a verdict, for both callers, shows its rules no
1731
+ -- longer loop; a read that failed for some other reason shows nothing.
1732
+ IF NOT (anon->>'ok')::boolean AND __KN__.recursion_in(anon->>'why') IS NOT NULL THEN
1733
+ looped := looped || jsonb_build_array(jsonb_build_object(
1734
+ 'relation', __KN__.recursion_in(anon->>'why'), 'table', tab->>'name', 'who', 'anyone'));
1735
+ END IF;
1736
+ IF NOT (as_a->>'ok')::boolean AND __KN__.recursion_in(as_a->>'why') IS NOT NULL THEN
1737
+ looped := looped || jsonb_build_array(jsonb_build_object(
1738
+ 'relation', __KN__.recursion_in(as_a->>'why'), 'table', tab->>'name', 'who', 'signed-in'));
1739
+ END IF;
1740
+ IF ((anon->>'ok')::boolean OR __KN__.refusal_means(anon->>'why') = 'unreachable')
1741
+ AND ((as_a->>'ok')::boolean OR __KN__.refusal_means(as_a->>'why') = 'unreachable') THEN
1742
+ completed := completed || to_jsonb('recursive:' || (tab->>'name'));
1743
+ END IF;
1744
+
1716
1745
  -- Did this read produce a verdict, and if not, why not?
1717
1746
  key := 'exposed:' || (tab->>'name');
1718
1747
  IF (anon->>'ok')::boolean THEN
@@ -1765,7 +1794,8 @@ BEGIN
1765
1794
  END IF;
1766
1795
  END LOOP;
1767
1796
 
1768
- RETURN jsonb_build_object('findings', findings, 'completed', completed, 'blocked', blocked);
1797
+ RETURN jsonb_build_object('findings', findings, 'completed', completed, 'blocked', blocked,
1798
+ 'looped', looped);
1769
1799
  END $$;
1770
1800
 
1771
1801
  -- --------------------------------------------------------------------------
@@ -2549,6 +2579,19 @@ BEGIN
2549
2579
 
2550
2580
  findings := (seen->'findings') || (wrote->'findings') || (stranded->'findings');
2551
2581
 
2582
+ -- A rule that looks itself up breaks the app for whoever it fails for.
2583
+ -- One finding per table at fault, however many reads ran into it; the
2584
+ -- attacks it stopped stay in not_checked, because they did not run.
2585
+ findings := findings || coalesce((
2586
+ SELECT jsonb_agg(jsonb_build_object(
2587
+ 'kind', 'recursive', 'table', g.relation, 'reads', g.reads,
2588
+ 'callers', g.callers, 'columns', '[]'::jsonb))
2589
+ FROM (SELECT l->>'relation' AS relation,
2590
+ jsonb_agg(DISTINCT l->>'table') AS reads,
2591
+ jsonb_agg(DISTINCT l->>'who') AS callers
2592
+ FROM jsonb_array_elements(coalesce(seen->'looped', '[]'::jsonb)) l
2593
+ GROUP BY l->>'relation') g), '[]'::jsonb);
2594
+
2552
2595
  -- Everything genuinely attacked, so a re-check can tell a finding that was
2553
2596
  -- fixed from one whose attack simply did not run this time.
2554
2597
  attempted := coalesce(seen->'completed', '[]'::jsonb)
package/finding.js CHANGED
@@ -20,7 +20,10 @@
20
20
  const IDENTITY = [
21
21
  [/\b(email|e_mail|mail)\b/i, 'email addresses'],
22
22
  [/\b(phone|mobile|contact_number|telephone)\b/i, 'phone numbers'],
23
- [/\b(full_name|first_name|last_name|given_name|surname|name)\b/i, 'names'],
23
+ // `\b` treats "_" as part of a word, so display_name never matched `name` -
24
+ // found on a blind test, where a public profiles table was reported as
25
+ // holding nothing in particular. The compound names are listed instead.
26
+ [/\b(full_name|first_name|last_name|given_name|surname|name|display_name|username|user_name|nickname|screen_name)\b/i, 'names'],
24
27
  [/\b(address|street|city|postcode|zip|pincode)\b/i, 'addresses'],
25
28
  [/\b(dob|date_of_birth|birth_date|birthday)\b/i, 'dates of birth'],
26
29
  ];
@@ -28,18 +31,37 @@ const IDENTITY = [
28
31
  // Things that are worse than identifying - they let somebody act as the person,
29
32
  // or take money.
30
33
  const SECRETS = [
31
- [/\b(password|passwd|pass_hash|password_hash)\b/i, 'passwords'],
32
- [/\b(token|api_key|apikey|secret|private_key|access_key)\b/i, 'access tokens'],
34
+ // `\b` counts "_" as a letter, so these missed every compound name - found
35
+ // on a blind test: integration_token, access_token and signing_secret, all
36
+ // readable by anyone, and the report named none of them. A name that ENDS
37
+ // in the secret word is the secret; token_expires_at is only about one.
38
+ // Singular only: `tokens` and `input_tokens` count what an AI call used -
39
+ // found on the next blind test, where api_usage.tokens (an integer) was
40
+ // named as a secret.
41
+ [/\b(password|passwd|pass_hash|password_hash)\b|(^|_)(password|passwd|pwd|password_hash)$/i, 'passwords'],
42
+ [/\b(token|api_key|apikey|secret|private_key|access_key)\b|(^|_)(token|secret|(api|private|access|secret|signing|service|encryption)_?key)$/i, 'access tokens and secrets'],
33
43
  [/\b(card|card_number|cvv|iban|account_number|upi)\b/i, 'payment details'],
44
+ // A code that grants something: whoever reads a moderator_code can become a
45
+ // moderator. Found on a blind test, added straight in production and readable
46
+ // by anyone - reported only as "2 rows".
47
+ [/^(?:\w+_)?(moderator|invite|invitation|access|reset|verification|recovery|admin|join|auth|login|magic|security|otp)_code$|^(otp|pin)$/i, 'secret codes'],
34
48
  ];
35
49
 
50
+ // Who holds power in the app. Not a secret in itself, but a list of exactly
51
+ // which accounts are worth attacking.
52
+ const PRIVILEGE = [/^(is_admin|is_superuser|is_super_admin|is_staff|is_moderator|is_owner|role|roles|permissions)$/i, 'who is an admin'];
53
+
36
54
  const MONEY = [/\b(amount|total|price|balance|salary|revenue|invoice)\b/i, 'amounts of money'];
37
55
 
56
+ // Each kind named with the columns that showed it: "secret codes
57
+ // (moderator_code)" can be found in the table; "secret codes" has to be
58
+ // looked for.
38
59
  function matched(columns, table) {
39
- const names = columns || [];
60
+ const names = (columns || []).map(String);
40
61
  const found = [];
41
62
  for (const [pattern, label] of table) {
42
- if (names.some((column) => pattern.test(String(column)))) found.push(label);
63
+ const hits = names.filter((column) => pattern.test(column));
64
+ if (hits.length) found.push(label + ' (' + hits.join(', ') + ')');
43
65
  }
44
66
  return found;
45
67
  }
@@ -49,7 +71,13 @@ function readContents(columns) {
49
71
  const identity = matched(columns, IDENTITY);
50
72
  const secrets = matched(columns, SECRETS);
51
73
  const money = matched(columns, [MONEY]);
52
- return { identity: identity, secrets: secrets, money: money };
74
+ const privilege = matched(columns, [PRIVILEGE]);
75
+ return { identity: identity, secrets: secrets, money: money, privilege: privilege };
76
+ }
77
+
78
+ /** Everything worth naming, worst first. */
79
+ function heldIn(contents) {
80
+ return contents.secrets.concat(contents.identity, contents.privilege || [], contents.money);
53
81
  }
54
82
 
55
83
  /** A list, written the way a person writes one. */
@@ -72,6 +100,11 @@ function listOf(items) {
72
100
  * should be fixed - there is no such thing as a low finding here.
73
101
  */
74
102
  function severityOf(finding, contents) {
103
+ if (finding.kind === 'recursive') {
104
+ // Nothing leaks and nothing is written: the request fails. Serious,
105
+ // because the app is broken for whoever it fails for, but not a break-in.
106
+ return 'HIGH';
107
+ }
75
108
  if (finding.kind === 'privileged') {
76
109
  // Not proven, so never CRITICAL: this is a reach that exists, not a break
77
110
  // that was demonstrated. A function anon can call that also writes is worth
@@ -125,6 +158,18 @@ function severityOf(finding, contents) {
125
158
  * one it is saves them looking in the wrong place.
126
159
  */
127
160
  function causeOf(finding) {
161
+ if (finding.kind === 'recursive') {
162
+ return {
163
+ short: 'a rule on it looks the same table up again',
164
+ long:
165
+ 'A row level security rule on this table checks something by reading ' +
166
+ 'this same table - usually "is this person a member?" asked of the ' +
167
+ 'members table itself. Reading the table means applying the rule, and ' +
168
+ 'applying the rule means reading the table, so Postgres stops the ' +
169
+ 'request with an error instead of going round forever. Any other ' +
170
+ 'table whose rule asks this one fails the same way.',
171
+ };
172
+ }
128
173
  if (finding.kind === 'orphaned') {
129
174
  return {
130
175
  short: 'nothing in the database ties the two tables together',
@@ -246,7 +291,19 @@ function causeOf(finding) {
246
291
  /** What a caller could do, written the way a person would say it. */
247
292
  const WRITE_WORDS = { add: 'add rows to', change: 'change rows in', delete: 'delete rows from' };
248
293
 
294
+ /** Who a looping rule fails for, said the way the headline and body need it. */
295
+ function loopVictims(finding) {
296
+ const callers = finding.callers || [];
297
+ if (callers.includes('anyone') && callers.includes('signed-in')) return 'everyone, signed in or not,';
298
+ if (callers.includes('anyone')) return 'visitors who are not logged in';
299
+ return 'signed-in users';
300
+ }
301
+
249
302
  function headlineFor(finding) {
303
+ if (finding.kind === 'recursive') {
304
+ return 'A rule on your ' + finding.table + ' table refers to itself, so ' + loopVictims(finding) +
305
+ ' get an error instead of data.';
306
+ }
250
307
  if (finding.kind === 'orphaned') {
251
308
  return 'Your ' + finding.table + ' table can point at a ' +
252
309
  finding.parent.replace(/s$/, '') + ' that does not exist.';
@@ -292,9 +349,27 @@ function costOf(finding) {
292
349
  }
293
350
 
294
351
  function bodyFor(finding, contents) {
295
- const holds = listOf(contents.secrets.concat(contents.identity, contents.money));
352
+ const holds = listOf(heldIn(contents));
296
353
  const rowWord = finding.readable === 1 ? 'row' : 'rows';
297
354
 
355
+ if (finding.kind === 'recursive') {
356
+ // Said as what happened to the requests, and then what it cost this
357
+ // report: the tables behind the error could not be tested for leaks.
358
+ const reads = finding.reads || [];
359
+ const callers = finding.callers || [];
360
+ const as = [];
361
+ if (callers.includes('anyone')) as.push('a visitor who is not logged in');
362
+ if (callers.includes('signed-in')) as.push('a signed-in user');
363
+ const one = reads.length === 1;
364
+ return (
365
+ 'Using ' + listOf(reads) + ' on a copy of your app as ' + as.join(' and as ') +
366
+ ', every request was refused with "infinite recursion detected in policy for relation ' +
367
+ finding.table + '". Every page in your app that uses ' + (one ? 'that table' : 'those tables') +
368
+ ' fails the same way for ' + loopVictims(finding).replace(/,$/, '') + '. It also means I could ' +
369
+ 'not test ' + (one ? 'it' : 'them') + ' for leaks - those attacks are listed at the end.'
370
+ );
371
+ }
372
+
298
373
  if (finding.kind === 'orphaned') {
299
374
  // Said as the thing a person will actually meet: a customer asks to be
300
375
  // deleted, and their rows are still here afterwards.
@@ -308,7 +383,7 @@ function bodyFor(finding, contents) {
308
383
  }
309
384
 
310
385
  if (finding.kind === 'writable') {
311
- const holds = listOf(contents.secrets.concat(contents.identity, contents.money));
386
+ const holds = listOf(heldIn(contents));
312
387
  const who = finding.who === 'anyone'
313
388
  ? 'Without logging in and without an account, I '
314
389
  : 'Signed in as one of your customers, I ';
@@ -437,9 +512,33 @@ function authTiesOf(plan) {
437
512
  * locks the app out of its own table. Or the table has no column saying whose
438
513
  * row it is - a waitlist, a contact form - so there is nothing to compare.
439
514
  */
515
+ // A column that says which organization a row belongs to. Such a table is
516
+ // not "nobody's" - its rows are a team's.
517
+ const TENANT_COLUMN = /^(org_id|organization_id|workspace_id|team_id|tenant_id|company_id)$/i;
518
+
440
519
  function serverOnlyLines(finding) {
441
520
  const table = finding.table;
442
521
  let why;
522
+ // Found on a fix test: api_usage has org_id, and the prompt said it had no
523
+ // column saying whose row it is. The assistant applying it had to correct
524
+ // the prompt; a less careful one would have locked the table away from the
525
+ // members who use it.
526
+ const tenant = finding.authTied !== false && finding.owned === false &&
527
+ (finding.columns || []).find((c) => TENANT_COLUMN.test(String(c)));
528
+ if (tenant) {
529
+ return [
530
+ 'Rows in "' + table + '" belong to an organization ("' + tenant + '"), not to one person. So the ' +
531
+ 'rule should let a signed-in user in only when they are a member of that row\'s organization - ' +
532
+ 'checked through your membership table, or a helper function that asks it - and give logged-out ' +
533
+ 'visitors nothing.',
534
+ '',
535
+ 'Write separate rules for reading, inserting, updating and deleting, each with that membership ' +
536
+ 'check (USING for reading, updating and deleting, WITH CHECK for inserting and updating), and ' +
537
+ 'decide whether every member should be able to write or only admins. If only your server ' +
538
+ 'writes this table, add no write rules at all.',
539
+ '',
540
+ ];
541
+ }
443
542
  if (finding.authTied === false) {
444
543
  why = 'Nothing in "' + table + '" points at auth.users and no rule on it uses auth.uid(), so your ' +
445
544
  'app signs people in its own way. A rule comparing a column to the signed-in user cannot ' +
@@ -520,7 +619,32 @@ function fixPromptFor(finding) {
520
619
  const cause = causeOf(finding);
521
620
  const owner = finding.owner ? '"' + finding.owner + '"' : 'the column that says who each row belongs to';
522
621
 
523
- const lines = finding.kind === 'privileged'
622
+ const lines = finding.kind === 'recursive'
623
+ ? [
624
+ 'My app has a broken database rule.',
625
+ '',
626
+ 'A row level security policy on the "' + finding.table + '" table reads "' + finding.table +
627
+ '" itself, so Postgres stops every request that touches it with "infinite recursion ' +
628
+ 'detected in policy for relation ' + finding.table + '". I saw it when using ' +
629
+ listOf((finding.reads || []).map((t) => '"' + t + '"')) + '.',
630
+ '',
631
+ 'Show me the policies on "' + finding.table + '" and on every table whose policy reads "' +
632
+ finding.table + '", and find the lookup that goes back into "' + finding.table + '".',
633
+ '',
634
+ 'Move that lookup into a SECURITY DEFINER function that answers only about the signed-in ' +
635
+ 'user - for example is_member_of(org uuid) returning whether auth.uid() is in that ' +
636
+ 'organization - with SET search_path fixed, owned by the table\'s owner so it reads the ' +
637
+ 'table without the rule applying again. Grant EXECUTE on it to authenticated only. Then ' +
638
+ 'make the policies call that function instead of reading "' + finding.table + '" directly.',
639
+ '',
640
+ 'Keep each policy exactly as strict as it was meant to be: members see only their own ' +
641
+ 'organization\'s rows, and logged-out visitors get nothing. Do not replace it with USING ' +
642
+ '(true) to make the error go away.',
643
+ '',
644
+ 'Then sign in as an ordinary user and open the pages that use these tables, and run this ' +
645
+ 'check again - the tables it could not test will be tested this time.',
646
+ ]
647
+ : finding.kind === 'privileged'
524
648
  ? [
525
649
  'My app may have a security problem - please check it rather than assume it.',
526
650
  '',
@@ -721,7 +845,10 @@ function describe(finding) {
721
845
  cause: finding.kind === 'privileged' ? '' : cause.long,
722
846
  who: finding.who,
723
847
  can: finding.can,
724
- proof: finding.kind === 'privileged'
848
+ proof: finding.kind === 'recursive'
849
+ ? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
850
+ '"infinite recursion detected in policy for relation ' + finding.table + '".'
851
+ : finding.kind === 'privileged'
725
852
  ? 'I did not call "' + finding.fn + '". This is a reach that exists in the grants, not a break I ran.'
726
853
  : finding.kind === 'duplicated'
727
854
  ? 'I created ' + finding.copies + ' rows in "' + finding.table + '" holding the same ' +
@@ -825,7 +952,9 @@ function describeAll(findings) {
825
952
  // ones that need an account, and those before the ones that need two requests
826
953
  // to arrive together.
827
954
  // Writes above reads: a table somebody emptied is worse than one they read.
828
- const byKind = { writable: 0, exposed: 1, crossed: 2, duplicated: 3, orphaned: 4, privileged: 5 };
955
+ // A looping rule sits with the reads: it breaks the app for everyone it
956
+ // fails for, which matters more than a duplicate or a stray row.
957
+ const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, duplicated: 3, orphaned: 4, privileged: 5 };
829
958
  const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
830
959
  return described.sort((a, b) => rank(a) - rank(b));
831
960
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon-night",
3
- "version": "0.1.10",
3
+ "version": "0.1.14",
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 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 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",
@@ -53,6 +53,8 @@
53
53
  "check:verdicts": "node verdicts.check.js",
54
54
  "check:shop": "node shop.check.js",
55
55
  "check:privileged": "node privileged.check.js",
56
+ "check:recursion": "node recursion.check.js",
57
+ "check:guarded": "node guarded.check.js",
56
58
  "check:cli": "node cli.check.js",
57
59
  "check:writes": "node tamper.check.js",
58
60
  "check:orphans": "node orphans.check.js",
package/recheck.js CHANGED
@@ -172,6 +172,7 @@ function whatOf(item) {
172
172
  if (item.kind === 'duplicated') return 'the same ' + (item.column || 'value') + ' twice';
173
173
  if (item.kind === 'orphaned') return 'rows left pointing at nothing';
174
174
  if (item.kind === 'privileged') return 'a function anyone can call';
175
+ if (item.kind === 'recursive') return 'a rule that refers to itself';
175
176
  return item.kind;
176
177
  }
177
178
 
@@ -191,7 +192,14 @@ function describe(result) {
191
192
  lines.push(' ' + result.fixed.length + (result.fixed.length === 1 ? ' problem is' : ' problems are') + ' fixed:');
192
193
  lines.push('');
193
194
  for (const item of result.fixed) {
194
- lines.push(' ' + item.table + ' (' + whatOf(item) + ') - I ran the same attack again and it was refused.');
195
+ // A function is never called, only read - so its fix is said as what
196
+ // was seen, not as an attack that was refused. Found on a fix test.
197
+ const how = item.kind === 'privileged'
198
+ ? 'I looked again, and a visitor with no account can no longer call it.'
199
+ : item.kind === 'recursive'
200
+ ? 'I read it again, and its rules answered instead of stopping with an error.'
201
+ : 'I ran the same attack again and it was refused.';
202
+ lines.push(' ' + item.table + ' (' + whatOf(item) + ') - ' + how);
195
203
  }
196
204
  lines.push('');
197
205
  }
package/scan.js CHANGED
@@ -269,6 +269,10 @@ async function scan(client, sourceSchema, options) {
269
269
  const viewNames = new Set(views.map((view) => view.name));
270
270
  const attempted = impersonation.completed.filter((key) => {
271
271
  const name = key.split(':')[1];
272
+ // Whether a rule loops needs no rows: Postgres finds the loop while
273
+ // planning the read, before it looks at a single row. A table nothing
274
+ // could be seeded into has still been checked for that.
275
+ if (key.startsWith('recursive:')) return true;
272
276
  return sownTables.has(name) || (viewNames.has(name) && sown.seeded.length > 0);
273
277
  });
274
278
 
@@ -310,6 +314,25 @@ async function scan(client, sourceSchema, options) {
310
314
  });
311
315
  }
312
316
 
317
+ // A rule that looks itself up. The attacks it stopped stay in the list of
318
+ // what was not tested - they did not run - but the loop is also a finding
319
+ // of its own: every signed-in request that touches the table fails, so the
320
+ // app is broken for the people using it. One finding per table at fault,
321
+ // named from Postgres's own message, however many reads ran into it.
322
+ const loops = new Map();
323
+ const loopedInto = (relation, table, who) => {
324
+ if (!loops.has(relation)) loops.set(relation, { kind: 'recursive', table: relation, reads: [], callers: [], columns: [] });
325
+ const entry = loops.get(relation);
326
+ if (!entry.reads.includes(table)) entry.reads.push(table);
327
+ if (!entry.callers.includes(who)) entry.callers.push(who);
328
+ };
329
+ for (const hit of impersonation.looped || []) loopedInto(hit.relation, hit.table, hit.who);
330
+ for (const stuck of writes.blocked) {
331
+ const relation = attack.recursionIn(stuck.why);
332
+ if (relation) loopedInto(relation, stuck.table, /^as anyone:/.test(stuck.why) ? 'anyone' : 'signed-in');
333
+ }
334
+ const recursive = Array.from(loops.values());
335
+
313
336
  let collisions = { findings: [], notTried: [], raced: [] };
314
337
  if (opts.openSession) {
315
338
  say(' Racing two requests against each other ...');
@@ -362,7 +385,9 @@ async function scan(client, sourceSchema, options) {
362
385
  // version recorded only the flagged ones, so a revoke made the function
363
386
  // vanish from both lists and the fix came back as "could not confirm".
364
387
  for (const fn of plan.anonFunctions || []) attempted.push('privileged:' + fn.name);
365
- const privileged = (plan.anonFunctions || []).filter((fn) => fn.callable).map((fn) => ({
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) => ({
366
391
  kind: 'privileged',
367
392
  fn: fn.name,
368
393
  table: fn.name,
@@ -389,7 +414,7 @@ async function scan(client, sourceSchema, options) {
389
414
  notChecked: notChecked,
390
415
  attempted: attempted,
391
416
  findings: finding.describeAll(
392
- impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged).map(tagged),
417
+ impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged, recursive).map(tagged),
393
418
  ),
394
419
  };
395
420
  } finally {
package/schema.js CHANGED
@@ -206,6 +206,49 @@ async function readIndexes(client, schema) {
206
206
  * re-check needs to know the function was looked at again and the grant was
207
207
  * gone, which is different from the function no longer being there at all.
208
208
  */
209
+ /**
210
+ * Whether a function turns the caller away before it does anything.
211
+ *
212
+ * Found on a blind test: create_admin_export opens with
213
+ * if not public.is_org_admin(p_org_id) then raise exception ...
214
+ * and was reported as "anyone can call it, with full rights". A visitor with
215
+ * no account is not an admin of anything, so the first line refuses them and
216
+ * nothing else runs. That is a guard, and the function is examined, not
217
+ * reported.
218
+ *
219
+ * Only a refusal counts - IF NOT <check> THEN RAISE - and only one that comes
220
+ * before the first write and the first RETURN, because a check made after the
221
+ * work is done guards nothing. The check has to ask about the caller: a call
222
+ * to one of the schema's yes/no helpers that ask auth.uid(), or a condition
223
+ * of its own that asks auth.uid() whether they are an admin, owner or member.
224
+ * "Is anybody signed in at all" is not enough, and neither is a check on an
225
+ * argument the caller chose.
226
+ */
227
+ function guardedAtTheDoor(body, helpers) {
228
+ const text = String(body || '');
229
+ const writes = /\b(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\b/i.exec(text);
230
+ const returns = /\breturn\b/i.exec(text);
231
+ const firstAct = Math.min(writes ? writes.index : Infinity, returns ? returns.index : Infinity);
232
+ // The condition may not run past a THEN, or two IFs would be read as one;
233
+ // and END IF is not the start of one.
234
+ const guard = /(?<!\bend\s+)\bif\b((?:(?!\bthen\b)[\s\S])*?)\bthen\s+raise\b(?!\s+(?:notice|warning|info|log|debug)\b)/gi;
235
+ // The name as a call, bare or schema-qualified (public.is_org_admin), and
236
+ // never as the tail of a longer name.
237
+ const asks = (cond, name) => new RegExp('(^|[^\\w$"])"?' +
238
+ name.replace(/[$]/g, '\\$') + '"?\\s*\\(', 'i').test(cond);
239
+ let found;
240
+ while ((found = guard.exec(text)) !== null) {
241
+ if (found.index > firstAct) break;
242
+ const cond = found[1].trim();
243
+ // Refusing when the check fails, not when it passes.
244
+ const refusesOnNo = /^not\b/i.test(cond) || /\bis\s+(not\s+true|false)\s*$/i.test(cond) || /=\s*false\s*$/i.test(cond);
245
+ if (!refusesOnNo) continue;
246
+ if ((helpers || []).some((name) => asks(cond, name))) return true;
247
+ if (/\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(cond) && /admin|owner|member|role/i.test(cond)) return true;
248
+ }
249
+ return false;
250
+ }
251
+
209
252
  async function readAnonDefinerFunctions(client, schema, role) {
210
253
  const caller = role || 'anon';
211
254
  // A plain Postgres with no anon role has no anonymous caller at all, so
@@ -218,6 +261,7 @@ async function readAnonDefinerFunctions(client, schema, role) {
218
261
  pg_get_function_identity_arguments(p.oid) AS args,
219
262
  p.proconfig::text AS config,
220
263
  pg_get_functiondef(p.oid) AS def,
264
+ p.prorettype = 'pg_catalog.bool'::regtype AS returns_bool,
221
265
  ${exists.rows.length ? "has_function_privilege($2, p.oid, 'EXECUTE')" : 'false'} AS callable
222
266
  FROM pg_proc p
223
267
  JOIN pg_namespace n ON n.oid = p.pronamespace
@@ -227,6 +271,21 @@ async function readAnonDefinerFunctions(client, schema, role) {
227
271
  ORDER BY p.proname`,
228
272
  exists.rows.length ? [schema, caller] : [schema],
229
273
  );
274
+ // The schema's yes/no helpers about the caller - is_org_admin(org) asking
275
+ // whether auth.uid() is an admin of it - definer or not, since a guard can
276
+ // call either. Read once, for guardedAtTheDoor.
277
+ const { rows: yesNo } = await client.query(
278
+ `SELECT p.proname AS name, pg_get_functiondef(p.oid) AS def
279
+ FROM pg_proc p
280
+ JOIN pg_namespace n ON n.oid = p.pronamespace
281
+ WHERE n.nspname = $1 AND p.prokind = 'f' AND p.prorettype = 'pg_catalog.bool'::regtype`,
282
+ [schema],
283
+ );
284
+ const helpers = yesNo.filter((row) => {
285
+ const body = String(row.def || '').replace(/--[^\n]*/g, ' ');
286
+ return /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(body) &&
287
+ !/\b(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\b/i.test(body);
288
+ }).map((row) => row.name);
230
289
  return rows.map((row) => {
231
290
  // A definer function with no search_path pinned is a second, separate
232
291
  // hazard: the caller can set their own search_path and make the function
@@ -238,11 +297,25 @@ async function readAnonDefinerFunctions(client, schema, role) {
238
297
  // could fool this, and that is acceptable: it never turns a non-finding
239
298
  // into a finding, only "can read" into "can change".
240
299
  const body = String(row.def || '').replace(/--[^\n]*/g, ' ');
241
- const writes = /\b(insert\s+into|update\s+\w|delete\s+from|truncate|merge\s+into)\b/i.test(body);
300
+ // `update\s+\w` followed by \b matched only a one-letter table name, so
301
+ // UPDATE was never seen - archive_notebook was worded as reading. The
302
+ // whole name, quoted or not, is matched now.
303
+ const writes = /\b(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\b/i.test(body);
304
+ // A yes/no about the caller alone - is_org_member(org) asking whether
305
+ // auth.uid() belongs to it. Found on a blind test, where two such helpers
306
+ // were reported as "anyone can call it, with full rights": they are how
307
+ // row level security avoids checking a table against itself, and to a
308
+ // visitor with no account they answer no. Reading nothing about anybody
309
+ // else and writing nothing, they are not a reach. A yes/no that does not
310
+ // ask auth.uid() - is_admin(user_id) - answers about other people, and
311
+ // stays a finding.
312
+ const aboutCaller = row.returns_bool === true && !writes && /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(body);
242
313
  return {
243
314
  name: row.name,
244
315
  args: row.args || '',
245
316
  writes: writes,
317
+ aboutCaller: aboutCaller,
318
+ guarded: guardedAtTheDoor(body, helpers),
246
319
  hasFixedSearchPath: hasFixedSearchPath,
247
320
  callable: row.callable === true,
248
321
  };
@@ -1192,6 +1265,7 @@ function diffSchemas(source, copy) {
1192
1265
  module.exports = {
1193
1266
  readSchema: readSchema,
1194
1267
  readAnonDefinerFunctions: readAnonDefinerFunctions,
1268
+ guardedAtTheDoor: guardedAtTheDoor,
1195
1269
  writeSchema: writeSchema,
1196
1270
  diffSchemas: diffSchemas,
1197
1271
  readPolicies: readPolicies,