kryptheon-night 0.1.9 → 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
@@ -31,6 +31,39 @@ const USER_D = '66666666-6666-4666-8666-666666666666';
31
31
  // where the row id IS the person.
32
32
  const OWNER_NAMES = ['user_id', 'owner_id', 'owner', 'profile_id', 'account_id', 'created_by', 'author_id'];
33
33
 
34
+ // A key to Supabase's auth.users as the app wrote it. In the copy it points
35
+ // at a stand-in named kn_ext__auth__users, which the table-of-people rule
36
+ // below already recognises; this one is for a table itself called users.
37
+ const TO_AUTH_USERS = /REFERENCES\s+"?auth"?\s*\.\s*"?users"?\s*\(/i;
38
+ // A table that looks like it holds people, keyed by the person.
39
+ const PERSON_TABLE = /(profile|user|account|member)/i;
40
+
41
+ /**
42
+ * The single-column keys from a uuid column, with what they point at. Read
43
+ * from the constraint wording itself, because a key to auth.users and a key
44
+ * to public.users are different things and the schema is part of the words.
45
+ */
46
+ function personKeys(table, uuids) {
47
+ const byName = new Set(uuids.map((c) => c.name));
48
+ const keys = [];
49
+ for (const constraint of table.constraints || []) {
50
+ if (constraint.kind !== 'f') continue;
51
+ const definition = String(constraint.definition || '');
52
+ const match = /FOREIGN KEY \(([^)]+)\) REFERENCES ([^(]+)\(([^)]+)\)/i.exec(definition);
53
+ if (!match) continue;
54
+ const columns = match[1].split(',').map((t) => t.trim().split('"').join(''));
55
+ const refColumns = match[3].split(',').map((t) => t.trim().split('"').join(''));
56
+ if (columns.length !== 1 || !byName.has(columns[0])) continue;
57
+ keys.push({
58
+ column: columns[0],
59
+ toAuth: TO_AUTH_USERS.test(definition),
60
+ refTable: match[2].split('.').pop().trim().split('"').join(''),
61
+ refColumn: refColumns[0],
62
+ });
63
+ }
64
+ return keys;
65
+ }
66
+
34
67
  /** The column that says who a row belongs to, or null if nothing does. */
35
68
  function ownerColumn(table) {
36
69
  const uuids = table.columns.filter((c) => c.type === 'uuid');
@@ -38,6 +71,19 @@ function ownerColumn(table) {
38
71
  const found = uuids.find((c) => c.name === name);
39
72
  if (found) return found.name;
40
73
  }
74
+ // Not named like an owner, but pointing at a person. Found on a blind test:
75
+ // appointments.provider_id -> profiles.id -> auth.users. Judged by name
76
+ // alone it had no owner, so "one provider reads another's patients" - a
77
+ // rule written USING (true) - was never tried, and the worst hole in the
78
+ // app went unreported. `id` first: a table keyed by the person is theirs.
79
+ // Two people on one row - sender_id and recipient_id - and neither is
80
+ // named: whose row it is cannot be told, and guessing would report one
81
+ // person reading a message sent to them as a break. So only one, or none.
82
+ const people = personKeys(table, uuids).filter((k) => k.toAuth ||
83
+ (k.refColumn === 'id' && k.refTable !== table.name && PERSON_TABLE.test(k.refTable)));
84
+ const columns = Array.from(new Set(people.map((k) => k.column)));
85
+ if (columns.includes('id')) return 'id';
86
+ if (columns.length === 1) return columns[0];
41
87
  // A profiles table keyed by the person themselves.
42
88
  if (/profile|user|account|member/i.test(table.name)) {
43
89
  const id = uuids.find((c) => c.name === 'id');
@@ -456,6 +502,22 @@ function refusalMeans(message) {
456
502
  return denied.test(String(message)) ? 'unreachable' : 'untested';
457
503
  }
458
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
+
459
521
  /**
460
522
  * What each table gives away, and to whom.
461
523
  *
@@ -471,6 +533,8 @@ async function impersonate(client, schema, tables) {
471
533
  const findings = [];
472
534
  const completed = [];
473
535
  const blocked = [];
536
+ // Rules that looked themselves up, and which read ran into each one.
537
+ const looped = [];
474
538
 
475
539
  /** Did this read produce a verdict, and if not, why not? */
476
540
  const settle = (key, table, answer, as) => {
@@ -493,6 +557,19 @@ async function impersonate(client, schema, tables) {
493
557
  const anon = await readAs(client, schema, table.name, 'anon', null);
494
558
  const asA = await readAs(client, schema, table.name, 'authenticated', USER_A);
495
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
+
496
573
  if (settle('exposed:' + table.name, table.name, anon, 'as a logged-out visitor') && anon.length > 0) {
497
574
  findings.push({
498
575
  kind: 'exposed',
@@ -521,7 +598,7 @@ async function impersonate(client, schema, tables) {
521
598
  }
522
599
  }
523
600
 
524
- return { findings: findings, completed: completed, blocked: blocked };
601
+ return { findings: findings, completed: completed, blocked: blocked, looped: looped };
525
602
  }
526
603
 
527
604
  /** A stable shape for comparing one run against another. */
@@ -539,6 +616,7 @@ module.exports = {
539
616
  USER_C: USER_C,
540
617
  USER_D: USER_D,
541
618
  refusalMeans: refusalMeans,
619
+ recursionIn: recursionIn,
542
620
  ownerColumn: ownerColumn,
543
621
  fitTo: fitTo,
544
622
  foreignKeys: foreignKeys,
package/engine.sql CHANGED
@@ -1006,6 +1006,7 @@ RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
1006
1006
  DECLARE
1007
1007
  wanted text;
1008
1008
  found text;
1009
+ people text[];
1009
1010
  BEGIN
1010
1011
  FOREACH wanted IN ARRAY ARRAY['user_id', 'owner_id', 'owner', 'profile_id',
1011
1012
  'account_id', 'created_by', 'author_id'] LOOP
@@ -1016,6 +1017,28 @@ BEGIN
1016
1017
  IF found IS NOT NULL THEN RETURN found; END IF;
1017
1018
  END LOOP;
1018
1019
 
1020
+ -- Not named like an owner, but a single-column key from a uuid column to a
1021
+ -- person: auth.users (or the copy's stand-in for it), or the id of a table
1022
+ -- that holds people. Found on a blind test: appointments.provider_id ->
1023
+ -- profiles.id was never tried. `id` first; two such columns (sender_id and
1024
+ -- recipient_id) cannot say whose row it is, so they name no owner.
1025
+ SELECT array_agg(DISTINCT k.col) INTO people
1026
+ FROM (
1027
+ SELECT __KN__.unquoted(p[1]) AS col,
1028
+ (con->>'definition') ~* 'REFERENCES\s+"?auth"?\s*\.\s*"?users"?\s*\(' AS to_auth,
1029
+ __KN__.unquoted(split_part(p[2], '.', greatest(array_length(string_to_array(p[2], '.'), 1), 1))) AS ref_table,
1030
+ __KN__.unquoted(p[3]) AS ref_column
1031
+ FROM jsonb_array_elements(coalesce(tab->'constraints', '[]'::jsonb)) con,
1032
+ regexp_match(con->>'definition', 'FOREIGN KEY \(([^),]+)\) REFERENCES ([^(]+)\(([^),]+)\)', 'i') p
1033
+ WHERE con->>'kind' = 'f' AND p IS NOT NULL
1034
+ ) k
1035
+ WHERE EXISTS (SELECT 1 FROM jsonb_array_elements(tab->'columns') c
1036
+ WHERE c->>'type' = 'uuid' AND c->>'name' = k.col)
1037
+ AND (k.to_auth OR (k.ref_column = 'id' AND k.ref_table <> tab->>'name'
1038
+ AND k.ref_table ~* '(profile|user|account|member)'));
1039
+ IF 'id' = ANY (coalesce(people, '{}')) THEN RETURN 'id'; END IF;
1040
+ IF array_length(people, 1) = 1 THEN RETURN people[1]; END IF;
1041
+
1019
1042
  -- A profiles table keyed by the person themselves.
1020
1043
  IF (tab->>'name') ~* '(profile|user|account|member)' THEN
1021
1044
  SELECT c->>'name' INTO found
@@ -1590,6 +1613,17 @@ RETURNS text LANGUAGE sql IMMUTABLE AS $$
1590
1613
  THEN 'unreachable' ELSE 'untested' END;
1591
1614
  $$;
1592
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
+
1593
1627
  /*
1594
1628
  * Reads a table the way a request would, as whoever is asking.
1595
1629
  *
@@ -1674,6 +1708,8 @@ DECLARE
1674
1708
  findings jsonb := '[]'::jsonb;
1675
1709
  completed jsonb := '[]'::jsonb;
1676
1710
  blocked jsonb := '[]'::jsonb;
1711
+ -- Rules that looked themselves up, and which read ran into each one.
1712
+ looped jsonb := '[]'::jsonb;
1677
1713
  tab jsonb;
1678
1714
  owner text;
1679
1715
  anon jsonb;
@@ -1690,6 +1726,22 @@ BEGIN
1690
1726
  anon := __KN__.read_as(source, tab->>'name', 'anon', NULL, NULL);
1691
1727
  as_a := __KN__.read_as(source, tab->>'name', 'authenticated', __KN__.user_a(), owner);
1692
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
+
1693
1745
  -- Did this read produce a verdict, and if not, why not?
1694
1746
  key := 'exposed:' || (tab->>'name');
1695
1747
  IF (anon->>'ok')::boolean THEN
@@ -1742,7 +1794,8 @@ BEGIN
1742
1794
  END IF;
1743
1795
  END LOOP;
1744
1796
 
1745
- RETURN jsonb_build_object('findings', findings, 'completed', completed, 'blocked', blocked);
1797
+ RETURN jsonb_build_object('findings', findings, 'completed', completed, 'blocked', blocked,
1798
+ 'looped', looped);
1746
1799
  END $$;
1747
1800
 
1748
1801
  -- --------------------------------------------------------------------------
@@ -1862,6 +1915,25 @@ END $$;
1862
1915
  * `seeded` carries the shape of row that worked when the table was seeded, so
1863
1916
  * the insert here is not rejected by some CHECK the seeder already solved.
1864
1917
  */
1918
+ /*
1919
+ * Whose name an insert is written under: nobody's yet (user C), unless the
1920
+ * owner column is a key into one of the app's own tables, where only the two
1921
+ * seeded people exist - then the one who is not the signed-in caller.
1922
+ */
1923
+ CREATE OR REPLACE FUNCTION __KN__.write_as_for(tab jsonb, owner text, tables jsonb)
1924
+ RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
1925
+ BEGIN
1926
+ IF owner IS NOT NULL AND EXISTS (
1927
+ SELECT 1 FROM jsonb_array_elements(__KN__.foreign_keys(tab)) k
1928
+ WHERE jsonb_array_length(k->'columns') = 1 AND k->'columns'->>0 = owner
1929
+ AND EXISTS (SELECT 1 FROM jsonb_array_elements(coalesce(tables, '[]'::jsonb)) t
1930
+ WHERE t->>'name' = k->>'refTable'))
1931
+ THEN
1932
+ RETURN __KN__.user_a();
1933
+ END IF;
1934
+ RETURN __KN__.user_c();
1935
+ END $$;
1936
+
1865
1937
  CREATE OR REPLACE FUNCTION __KN__.tamper(source text, tables jsonb, seeded jsonb,
1866
1938
  policies jsonb DEFAULT '[]'::jsonb)
1867
1939
  RETURNS jsonb LANGUAGE plpgsql AS $$
@@ -1925,7 +1997,10 @@ BEGIN
1925
1997
  -- Written under a name nobody has used, which is both what makes the row
1926
1998
  -- land at all and what makes it the right test: adding a row of your own
1927
1999
  -- is the feature, adding one under somebody else's name is not.
1928
- row_ := __KN__.row_for(source, tab, __KN__.user_c(), '7', NULL, shape);
2000
+ -- Unless the owner is a key into one of the app's own tables, where only
2001
+ -- the two seeded people exist: then the one who is not the signed-in
2002
+ -- caller. Found on a blind test, where the nameless insert hit the key.
2003
+ row_ := __KN__.row_for(source, tab, __KN__.write_as_for(tab, owner, tables), '7', NULL, shape);
1929
2004
 
1930
2005
  -- Every statement written out before anybody changes role.
1931
2006
  moves := jsonb_build_array(
@@ -2504,6 +2579,19 @@ BEGIN
2504
2579
 
2505
2580
  findings := (seen->'findings') || (wrote->'findings') || (stranded->'findings');
2506
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
+
2507
2595
  -- Everything genuinely attacked, so a re-check can tell a finding that was
2508
2596
  -- fixed from one whose attack simply did not run this time.
2509
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 ';
@@ -410,11 +485,166 @@ function wrapTo(text, width) {
410
485
  return out;
411
486
  }
412
487
 
488
+ /**
489
+ * Whether the app signs people in through Supabase: any foreign key to
490
+ * auth.users, or any rule that asks auth.uid() or auth.jwt(). Decided for the
491
+ * whole app, not table by table. Found on a blind test: appointments points
492
+ * at profiles, which points at auth.users - no key of its own and no rule
493
+ * asking auth.uid(), so it was told the app "signs people in its own way"
494
+ * when it plainly used Supabase's. Read from the original schema, not the
495
+ * copy - the copy points those keys at a stand-in. A table missing from the
496
+ * map is unknown, which the prompts treat as tied.
497
+ */
498
+ function authTiesOf(plan) {
499
+ const tables = (plan && plan.tables) || [];
500
+ const keyed = tables.some((table) => (table.constraints || []).some((c) =>
501
+ c.kind === 'f' && /references\s+"?auth"?\s*\.\s*"?users"?\s*\(/i.test(String(c.definition || ''))));
502
+ const asked = ((plan && plan.policies) || []).some((policy) =>
503
+ /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(String(policy.qual || '') + ' ' + String(policy.with_check || '')));
504
+ return new Map(tables.map((table) => [table.name, keyed || asked]));
505
+ }
506
+
507
+ /**
508
+ * The fix for a table no rule can tie to the signed-in user, or null when one
509
+ * can. Two ways that happens. The app has its own login: found on a real app,
510
+ * "compare the owner to the id of the signed-in user" cannot be done there -
511
+ * auth.uid() is empty for everybody - and an assistant that writes it anyway
512
+ * locks the app out of its own table. Or the table has no column saying whose
513
+ * row it is - a waitlist, a contact form - so there is nothing to compare.
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
+
519
+ function serverOnlyLines(finding) {
520
+ const table = finding.table;
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
+ }
542
+ if (finding.authTied === false) {
543
+ why = 'Nothing in "' + table + '" points at auth.users and no rule on it uses auth.uid(), so your ' +
544
+ 'app signs people in its own way. A rule comparing a column to the signed-in user cannot ' +
545
+ 'work here - auth.uid() is empty for everyone - and adding one would lock your own app out.';
546
+ } else if (finding.owned === false) {
547
+ why = '"' + table + '" has no column that says whose row it is, so a rule tying rows to the ' +
548
+ 'signed-in user has nothing to compare against.';
549
+ } else {
550
+ return null;
551
+ }
552
+ return [
553
+ why,
554
+ '',
555
+ 'If only your server reads and writes this table: keep row level security on, drop the rules ' +
556
+ 'that let everyone in, and add none for anon or authenticated. Have your server use the ' +
557
+ 'service_role key, which skips these rules - and keep that key on the server only, never ' +
558
+ 'in the browser or in the app\'s public code.',
559
+ '',
560
+ 'If the browser needs to read or write part of it directly, tell me which part first, so we ' +
561
+ 'write the narrowest rule for exactly that.',
562
+ '',
563
+ ];
564
+ }
565
+
566
+ /**
567
+ * One prompt for everything wrong with one table's rules - read and write,
568
+ * strangers and customers - because it is one rule to replace, not four.
569
+ */
570
+ function groupPromptFor(members) {
571
+ const lead = members.find((m) => m.kind === 'writable') || members[0];
572
+ const cause = causeOf(lead);
573
+ const table = lead.table;
574
+ const owner = (members.find((m) => m.owner) || {}).owner;
575
+ const ownerWords = owner ? '"' + owner + '"' : 'the column that says who each row belongs to';
576
+ const read = members.find((m) => m.kind === 'exposed');
577
+ const writers = members.filter((m) => m.kind === 'writable');
578
+ const anyoneWrites = writers.find((w) => w.who === 'anyone');
579
+ const customersWrite = writers.find((w) => w.who !== 'anyone');
580
+ const what = [];
581
+ if (read) what.push('read');
582
+ if (writers.length) what.push('written');
583
+ const who = read || anyoneWrites ? 'anyone who is not logged in' : 'any signed-in user, including other users\' rows';
584
+ const did = [];
585
+ if (read) did.push('read ' + read.readable + ' ' + (read.readable === 1 ? 'row' : 'rows'));
586
+ const moves = [];
587
+ for (const w of writers) for (const m of w.can || []) if (!moves.includes(m)) moves.push(m);
588
+ for (const m of ['add', 'change', 'delete']) {
589
+ if (moves.includes(m)) did.push({ add: 'added a row', change: 'changed rows', delete: 'deleted rows' }[m]);
590
+ }
591
+ const lines = [
592
+ 'My app has a security problem.',
593
+ '',
594
+ 'The "' + table + '" table can be ' + what.join(' and ') + ' by ' + who +
595
+ ', because ' + cause.short + '. I proved it on a copy: I ' + listOf(did) + '.' +
596
+ (anyoneWrites && customersWrite ? ' Signed-in users can do the same.' : ''),
597
+ '',
598
+ ].concat(
599
+ 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.',
603
+ '',
604
+ 'Each of them should compare ' + ownerWords + ' to the id of the signed-in user (auth.uid()): ' +
605
+ 'USING for reading, updating and deleting, WITH CHECK for inserting and updating, so nobody ' +
606
+ 'reads, changes or writes a row under somebody else\'s name. Give logged-out visitors no rule ' +
607
+ 'at all unless they truly need one.',
608
+ '',
609
+ ],
610
+ ['Then check every other table for the same thing.'],
611
+ );
612
+ return lines
613
+ .map((paragraph) => (paragraph ? wrapTo(paragraph, 72) : ['']))
614
+ .reduce((all, part) => all.concat(part), [])
615
+ .join('\n');
616
+ }
617
+
413
618
  function fixPromptFor(finding) {
414
619
  const cause = causeOf(finding);
415
620
  const owner = finding.owner ? '"' + finding.owner + '"' : 'the column that says who each row belongs to';
416
621
 
417
- 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'
418
648
  ? [
419
649
  'My app may have a security problem - please check it rather than assume it.',
420
650
  '',
@@ -474,17 +704,24 @@ function fixPromptFor(finding) {
474
704
  (finding.can || []).map((what) => ({ add: 'added a row', change: 'changed rows', delete: 'deleted rows' })[what])
475
705
  .join(', ') + '.',
476
706
  '',
477
- 'Switch row level security on for this table, then write separate rules for ' +
478
- 'reading, inserting, updating and deleting. A rule written FOR SELECT does ' +
479
- 'not cover writes, and a rule written FOR ALL covers far more than reading.',
480
- '',
481
- 'For insert and update, use WITH CHECK comparing ' + owner +
482
- ' to the id of the signed-in user, so nobody can write a row under somebody ' +
483
- "else's name.",
484
- '',
485
- 'Then check every other table for the same thing - a table with no row level ' +
486
- 'security on it is writable by default.',
487
- ]
707
+ ].concat(
708
+ 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.',
712
+ '',
713
+ 'For insert and update, use WITH CHECK comparing ' + owner +
714
+ ' to the id of the signed-in user, so nobody can write a row under somebody ' +
715
+ "else's name. For update and delete, add USING with the same comparison: " +
716
+ 'with USING (true) left on the update, anyone can take a row that is not ' +
717
+ 'theirs and rewrite it as their own.',
718
+ '',
719
+ ],
720
+ [
721
+ 'Then check every other table for the same thing - a table with no row level ' +
722
+ 'security on it is writable by default.',
723
+ ],
724
+ )
488
725
  : finding.kind === 'duplicated'
489
726
  ? [
490
727
  'My app has a security problem.',
@@ -559,11 +796,14 @@ function fixPromptFor(finding) {
559
796
  ? 'can be read by anyone who is not logged in, because ' + cause.short + '.'
560
797
  : 'lets one signed-in user read rows belonging to a different user, because ' + cause.short + '.'),
561
798
  '',
562
- 'Fix it so a person can only read their own rows: compare ' + owner +
563
- ' against the id of the signed-in user, and make sure logged-out visitors get nothing.',
564
- '',
565
- 'Then look for the same mistake on every other table and fix those too.',
566
- ];
799
+ ].concat(
800
+ serverOnlyLines(finding) || [
801
+ 'Fix it so a person can only read their own rows: compare ' + owner +
802
+ ' against the id of the signed-in user, and make sure logged-out visitors get nothing.',
803
+ '',
804
+ ],
805
+ ['Then look for the same mistake on every other table and fix those too.'],
806
+ );
567
807
  // Wrapped here rather than by whoever prints it: this text is pasted into a
568
808
  // chat box as often as it is read in a terminal, and an unwrapped paragraph
569
809
  // is a wall in both.
@@ -605,7 +845,10 @@ function describe(finding) {
605
845
  cause: finding.kind === 'privileged' ? '' : cause.long,
606
846
  who: finding.who,
607
847
  can: finding.can,
608
- 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'
609
852
  ? 'I did not call "' + finding.fn + '". This is a reach that exists in the grants, not a break I ran.'
610
853
  : finding.kind === 'duplicated'
611
854
  ? 'I created ' + finding.copies + ' rows in "' + finding.table + '" holding the same ' +
@@ -620,6 +863,50 @@ function describe(finding) {
620
863
  };
621
864
  }
622
865
 
866
+ /** Everything wrong with one table's rules, said once. */
867
+ function mergeTable(group, one) {
868
+ const members = group.map(one);
869
+ const read = group.find((m) => m.kind === 'exposed');
870
+ const anyoneWrites = group.find((m) => m.kind === 'writable' && m.who === 'anyone');
871
+ const customersWrite = group.find((m) => m.kind === 'writable' && m.who !== 'anyone');
872
+ const lead = anyoneWrites || customersWrite || read;
873
+ const leadItem = members[group.indexOf(lead)];
874
+ const table = lead.table;
875
+ const order = ['delete', 'change', 'add'];
876
+ const does = (w) => order.filter((m) => (w.can || []).includes(m)).map((m) => WRITE_WORDS[m]);
877
+ let headline;
878
+ if (read && anyoneWrites) {
879
+ const verbs = ['read'].concat(order.filter((m) => (anyoneWrites.can || []).includes(m)));
880
+ headline = 'Anyone can ' + listOf(verbs) + ' rows in your ' + table + ' table.';
881
+ } else if (read && customersWrite) {
882
+ headline = 'Anyone can read your ' + table + ' table, and any signed-in customer can ' + listOf(does(customersWrite)) + ' it.';
883
+ } else {
884
+ headline = leadItem.headline;
885
+ }
886
+ // The customers' part is said in one line when strangers can already do it.
887
+ const bodies = [];
888
+ for (let i = 0; i < group.length; i++) {
889
+ if (group[i] === customersWrite && anyoneWrites) continue;
890
+ bodies.push(members[i].body);
891
+ }
892
+ if (customersWrite && anyoneWrites) bodies.push('Signed-in customers can do the same.');
893
+ const severity = members.some((m) => m.severity === 'CRITICAL') ? 'CRITICAL' : leadItem.severity;
894
+ const can = [];
895
+ for (const m of group) for (const c of m.can || []) if (!can.includes(c)) can.push(c);
896
+ return Object.assign({}, leadItem, {
897
+ severity: severity,
898
+ status: 'confirmed',
899
+ kind: lead.kind,
900
+ who: read || anyoneWrites ? 'anyone' : leadItem.who,
901
+ can: can,
902
+ headline: headline,
903
+ body: bodies.join(' '),
904
+ proof: members.map((m) => m.proof).join(' '),
905
+ fixPrompt: groupPromptFor(group),
906
+ members: members,
907
+ });
908
+ }
909
+
623
910
  /** The whole report, in the order a person should read it. */
624
911
  function describeAll(findings) {
625
912
  // A table anyone can read is also, necessarily, a table one customer can read
@@ -628,24 +915,46 @@ function describeAll(findings) {
628
915
  // is kept, and it carries the rest.
629
916
  const raw = findings || [];
630
917
  const publiclyOpen = new Set(raw.filter((f) => f.kind === 'exposed').map((f) => f.table));
631
- const described = raw
632
- .filter((f) => !(f.kind === 'crossed' && publiclyOpen.has(f.table)))
633
- .map((f) =>
634
- Object.assign(describe(f), {
635
- alsoCrossed:
636
- f.kind === 'exposed' && raw.some((o) => o.kind === 'crossed' && o.table === f.table),
637
- }),
638
- );
639
- for (const item of described) {
640
- if (item.alsoCrossed) {
641
- item.body += ' Your signed-in customers can read each other\'s rows for the same reason.';
918
+ const kept = raw.filter((f) => !(f.kind === 'crossed' && publiclyOpen.has(f.table)));
919
+ const one = (f) => {
920
+ const item = Object.assign(describe(f), {
921
+ alsoCrossed: f.kind === 'exposed' && raw.some((o) => o.kind === 'crossed' && o.table === f.table),
922
+ });
923
+ if (item.alsoCrossed) item.body += ' Your signed-in customers can read each other\'s rows for the same reason.';
924
+ return item;
925
+ };
926
+
927
+ // One table, one finding. A single rule written FOR ALL USING (true) opens a
928
+ // table to reading and writing, for strangers and customers alike - four
929
+ // proofs of one mistake. Listed as four, a real app's report showed six
930
+ // CRITICAL findings for two rules, and the person reads six problems where
931
+ // there are two to fix. The proofs are kept as members, so a re-check can
932
+ // still tell "strangers can no longer write" from "strangers can still read".
933
+ const grouped = new Map();
934
+ for (const f of kept) {
935
+ if ((f.kind !== 'exposed' && f.kind !== 'writable') || f.isView || f.countOnly) continue;
936
+ if (!grouped.has(f.table)) grouped.set(f.table, []);
937
+ grouped.get(f.table).push(f);
938
+ }
939
+ const described = [];
940
+ const done = new Set();
941
+ for (const f of kept) {
942
+ const group = grouped.get(f.table);
943
+ if (!group || group.length < 2 || group.indexOf(f) === -1) {
944
+ described.push(one(f));
945
+ continue;
642
946
  }
947
+ if (done.has(f.table)) continue;
948
+ done.add(f.table);
949
+ described.push(mergeTable(group, one));
643
950
  }
644
951
  // Worst first, and within that the ones open to the whole internet before the
645
952
  // ones that need an account, and those before the ones that need two requests
646
953
  // to arrive together.
647
954
  // Writes above reads: a table somebody emptied is worse than one they read.
648
- 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 };
649
958
  const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
650
959
  return described.sort((a, b) => rank(a) - rank(b));
651
960
  }
@@ -678,6 +987,7 @@ function allClearLines(attacksRun) {
678
987
  }
679
988
 
680
989
  module.exports = {
990
+ authTiesOf: authTiesOf,
681
991
  readContents: readContents,
682
992
  severityOf: severityOf,
683
993
  causeOf: causeOf,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon-night",
3
- "version": "0.1.9",
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
@@ -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) {
@@ -149,6 +160,22 @@ function badgeLines(result, attacksRun) {
149
160
  ];
150
161
  }
151
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
+ if (item.kind === 'recursive') return 'a rule that refers to itself';
176
+ return item.kind;
177
+ }
178
+
152
179
  /** What the re-check says, in the order it matters. */
153
180
  function describe(result) {
154
181
  const lines = [''];
@@ -165,7 +192,14 @@ function describe(result) {
165
192
  lines.push(' ' + result.fixed.length + (result.fixed.length === 1 ? ' problem is' : ' problems are') + ' fixed:');
166
193
  lines.push('');
167
194
  for (const item of result.fixed) {
168
- lines.push(' ' + item.table + ' - 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);
169
203
  }
170
204
  lines.push('');
171
205
  }
@@ -185,7 +219,7 @@ function describe(result) {
185
219
  lines.push(' ' + result.unverifiable.length + ' I could NOT confirm:');
186
220
  lines.push('');
187
221
  for (const item of result.unverifiable) {
188
- lines.push(' ' + item.table + ' - ' + item.why);
222
+ lines.push(' ' + item.table + ' (' + whatOf(item) + ') - ' + item.why);
189
223
  }
190
224
  lines.push('');
191
225
  lines.push(' These are not fixed and not broken - they are unknown. The problem');
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,
@@ -372,13 +397,24 @@ async function scan(client, sourceSchema, options) {
372
397
  columns: [],
373
398
  }));
374
399
 
400
+ // Whether each table is tied to Supabase's sign-in decides what its fix
401
+ // can be. Read from the original schema: the copy points auth.users keys
402
+ // at a stand-in, and would call every table untied.
403
+ const ties = finding.authTiesOf(plan);
404
+ // And whether a row says whose it is: with no such column, no rule can
405
+ // tie it to the person asking. Read from the copy, the way seeding did.
406
+ const owned = new Map(theirs.map((table) => [table.name, Boolean(attack.ownerColumn(table))]));
407
+ const tagged = (f) => Object.assign({}, f,
408
+ ties.has(f.table) ? { authTied: ties.get(f.table) } : {},
409
+ owned.has(f.table) ? { owned: owned.get(f.table) } : {});
410
+
375
411
  return {
376
412
  stopped: null,
377
413
  attacksRun: attempted.length,
378
414
  notChecked: notChecked,
379
415
  attempted: attempted,
380
416
  findings: finding.describeAll(
381
- impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged),
417
+ impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged, recursive).map(tagged),
382
418
  ),
383
419
  };
384
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,
package/tamper.js CHANGED
@@ -131,7 +131,13 @@ async function tamper(client, schema, tables, seeded, policies) {
131
131
  // Written under a name nobody has used, which is both what makes the
132
132
  // row land at all and what makes it the right test: adding a row of
133
133
  // your own is the feature, adding one under somebody else name is not.
134
- const row = await attack.rowFor(client, schema, table, attack.USER_C, 7, null, shapeFor.get(table.name));
134
+ //
135
+ // Unless the owner is a key into one of the app's own tables - provider_id
136
+ // -> profiles.id - where only the two seeded people exist. Found on a
137
+ // blind test: the nameless insert hit the foreign key, and "any provider
138
+ // can add appointments for any other" came back as not tested. The other
139
+ // seeded person is still somebody else's name to the signed-in caller.
140
+ const row = await attack.rowFor(client, schema, table, writeAsFor(table, owner, tables), 7, null, shapeFor.get(table.name));
135
141
  const placeholders = row.values.map((_, i) => '$' + (i + 1));
136
142
  const attempts = [
137
143
  row.columns.length
@@ -186,6 +192,18 @@ async function tamper(client, schema, tables, seeded, policies) {
186
192
  return { findings: findings, completed: completed, blocked: blocked };
187
193
  }
188
194
 
195
+ /**
196
+ * Whose name an insert is written under: nobody's yet (USER_C), unless the
197
+ * owner column is a key into one of the app's own tables, where only the two
198
+ * seeded people exist - then the one who is not the signed-in caller.
199
+ */
200
+ function writeAsFor(table, owner, tables) {
201
+ if (!owner) return attack.USER_C;
202
+ const key = attack.foreignKeys(table).find((k) => k.columns.length === 1 && k.columns[0] === owner);
203
+ const inApp = key && (tables || []).some((t) => t.name === key.refTable);
204
+ return inApp ? attack.USER_A : attack.USER_C;
205
+ }
206
+
189
207
  /**
190
208
  * A column an UPDATE can harmlessly set to itself.
191
209
  *
@@ -204,5 +222,6 @@ module.exports = {
204
222
  tryWrite: tryWrite,
205
223
  whatHappened: whatHappened,
206
224
  firstWritable: firstWritable,
225
+ writeAsFor: writeAsFor,
207
226
  tamper: tamper,
208
227
  };