kryptheon-night 0.1.31 → 0.1.32

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
@@ -531,6 +531,27 @@ function insertRow(client, schema, table, row, back) {
531
531
  );
532
532
  }
533
533
 
534
+ /**
535
+ * The column that says which organization's a row is, on a table with no
536
+ * person's name on it - or null. Its first key to a table that others point
537
+ * at (an organization, a workspace, a team), where the table itself is not one
538
+ * of those: seeding gives each person their own organization, and a table like
539
+ * this one row, in the first person's. Found on a blind test (HarborLine):
540
+ * integration_secrets(organization_id, access_token) was readable by every
541
+ * signed-in user, and with no person column it was never tried at all.
542
+ */
543
+ function tenantKey(table, tables, parents) {
544
+ // A view is not seeded: it shows whatever its tables hold, the second
545
+ // person's own organization included, so a row of it read by them proves
546
+ // nothing. Found on the same test: workspace_health, filtered to the
547
+ // organizations the reader belongs to, came back as a leak.
548
+ if (table.isView || parents.has(table.name)) return null;
549
+ for (const key of foreignKeys(table).concat(impliedKeys(table, tables))) {
550
+ if (key.refTable !== table.name && parents.has(key.refTable) && key.columns.length === 1) return key.columns[0];
551
+ }
552
+ return null;
553
+ }
554
+
534
555
  /** Every table some other table points at, by a key it declared or one it implies. */
535
556
  function parentTables(tables) {
536
557
  const parents = new Set();
@@ -619,7 +640,19 @@ async function seed(client, schema, tables) {
619
640
  // this table can use the same one instead of rediscovering it, and be
620
641
  // sure a refusal is the app defending itself rather than a CHECK it
621
642
  // never satisfied.
622
- seeded.push({ table: table.name, owner: owner, attempt: worked });
643
+ const entry = { table: table.name, owner: owner, attempt: worked };
644
+ // A table with nobody's name on it that got a row for each person - an
645
+ // organization's invoices, say - holds the second person's own row too.
646
+ // Which row is the first person's is kept, so a write meant for
647
+ // "somebody else's row" is aimed at it. Found on a blind test
648
+ // (HarborLine): an unscoped UPDATE changed the second person's own
649
+ // invoice, and was reported as any customer changing another's.
650
+ const firstRow = !owner && rows.get(table.name) && rows.get(table.name).get(USER_A);
651
+ const pk = primaryKeyOf(table);
652
+ if (firstRow && rows.get(table.name).size === 2 && pk && firstRow[pk] !== null && firstRow[pk] !== undefined) {
653
+ entry.rowOfA = { column: pk, value: String(firstRow[pk]) };
654
+ }
655
+ seeded.push(entry);
623
656
  } else {
624
657
  // Recorded, never swallowed. The report has to say this table was not
625
658
  // checked rather than let an empty table pass for a safe one.
@@ -747,8 +780,10 @@ async function impersonate(client, schema, tables) {
747
780
  return false;
748
781
  };
749
782
 
783
+ const parents = parentTables(tables);
750
784
  for (const table of tables) {
751
785
  const owner = ownerColumn(table);
786
+ const tenant = owner ? null : tenantKey(table, tables, parents);
752
787
  const anon = await readAs(client, schema, table.name, 'anon', null);
753
788
  const asA = await readAs(client, schema, table.name, 'authenticated', USER_A);
754
789
 
@@ -794,6 +829,27 @@ async function impersonate(client, schema, tables) {
794
829
  });
795
830
  }
796
831
  }
832
+
833
+ // A table whose rows are an organization's, not a person's: its one row is
834
+ // the first person's organization's, and the second person - signed in,
835
+ // in an organization of their own - asks for it. What a logged-out
836
+ // stranger can already read is reported as exposed, and not twice.
837
+ if (tenant) {
838
+ const asB = await readAs(client, schema, table.name, 'authenticated', USER_B);
839
+ const open = Array.isArray(anon) && anon.length > 0;
840
+ if (settle('crossed:' + table.name, table.name, asB, 'as a signed-in user in another organization') &&
841
+ asB.length > 0 && !open) {
842
+ findings.push({
843
+ kind: 'crossed',
844
+ table: table.name,
845
+ owner: tenant,
846
+ tenant: true,
847
+ readable: asB.length,
848
+ columns: Object.keys(asB[0] || {}),
849
+ rlsEnabled: table.rlsEnabled,
850
+ });
851
+ }
852
+ }
797
853
  }
798
854
 
799
855
  return { findings: findings, completed: completed, blocked: blocked, looped: looped };
package/engine.sql CHANGED
@@ -367,6 +367,169 @@ EXCEPTION WHEN OTHERS THEN
367
367
  RETURN '[]'::jsonb;
368
368
  END $$;
369
369
 
370
+ /*
371
+ * Whether a function turns the caller away before it does anything, and
372
+ * whether its only check is one somebody with no role slips past (schema.js
373
+ * doorOf, read the same way: IF <check> THEN RAISE, before the first write
374
+ * and the first RETURN). `helpers` are { name, nullable } - a yes/no about
375
+ * the caller, or a role helper that answers NULL for a stranger.
376
+ */
377
+ CREATE OR REPLACE FUNCTION __KN__.door_of(body text, helpers jsonb)
378
+ RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
379
+ DECLARE
380
+ text_ text := coalesce(body, '');
381
+ writes_re constant text := '\y(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\y';
382
+ guard_re constant text :=
383
+ '((?<!\yend\s)(?<!\yend\s\s)\yif\y((?:(?!\ythen\y).)*?)\ythen\s+raise\y(?!\s+(?:notice|warning|info|log|debug)\y))';
384
+ first_act integer := 2147483647;
385
+ m text[];
386
+ pos integer;
387
+ from_ integer := 1;
388
+ cond text;
389
+ refuses_on_no boolean;
390
+ refuses boolean;
391
+ null_safe boolean;
392
+ h jsonb;
393
+ asks boolean;
394
+ slips boolean := false;
395
+ BEGIN
396
+ IF text_ ~* writes_re THEN
397
+ first_act := least(first_act, length((regexp_split_to_array(text_, writes_re, 'i'))[1]) + 1);
398
+ END IF;
399
+ IF text_ ~* '\yreturn\y' THEN
400
+ first_act := least(first_act, length((regexp_split_to_array(text_, '\yreturn\y', 'i'))[1]) + 1);
401
+ END IF;
402
+ FOR m IN SELECT regexp_matches(text_, guard_re, 'gi') LOOP
403
+ pos := from_ + strpos(substr(text_, from_), m[1]) - 1;
404
+ from_ := pos + length(m[1]);
405
+ EXIT WHEN pos > first_act;
406
+ cond := btrim(m[2]);
407
+ -- Refusing when the check fails, not when it passes.
408
+ refuses_on_no := cond ~* '^not\y' OR cond ~* '\yis\s+(not\s+true|false)\s*$' OR cond ~* '=\s*false\s*$';
409
+ FOR h IN SELECT * FROM jsonb_array_elements(coalesce(helpers, '[]'::jsonb)) LOOP
410
+ asks := cond ~* ('(^|[^\w$"])"?' || replace(h->>'name', '$', '\$') || '"?\s*\(');
411
+ CONTINUE WHEN NOT asks;
412
+ IF NOT coalesce((h->>'nullable')::boolean, false) THEN
413
+ IF refuses_on_no THEN RETURN jsonb_build_object('guarded', true, 'nullSlips', false); END IF;
414
+ ELSE
415
+ refuses := refuses_on_no OR cond ~* '\ynot\s+in\s*\(' OR cond ~ '<>|!=';
416
+ null_safe := cond ~* '\ycoalesce\s*\(' OR cond ~* '\yis\s+null\y' OR cond ~* '\yis\s+not\s+true\s*$';
417
+ IF refuses AND null_safe THEN RETURN jsonb_build_object('guarded', true, 'nullSlips', false); END IF;
418
+ IF refuses THEN slips := true; END IF;
419
+ END IF;
420
+ END LOOP;
421
+ IF refuses_on_no AND cond ~* '\yauth\s*\.\s*(uid|jwt)\s*\(' AND cond ~* 'admin|owner|member|role' THEN
422
+ RETURN jsonb_build_object('guarded', true, 'nullSlips', false);
423
+ END IF;
424
+ END LOOP;
425
+ RETURN jsonb_build_object('guarded', false, 'nullSlips', slips);
426
+ END $$;
427
+
428
+ /*
429
+ * Every SECURITY DEFINER function in the schema, trigger functions aside, and
430
+ * what the report needs to know about each (schema.js
431
+ * readAnonDefinerFunctions). Found on a blind test (HarborLine): the nightly
432
+ * run never looked at functions at all, so mark_invoice_paid, set_team_role
433
+ * and every other open one showed on the command line and never on the
434
+ * dashboard.
435
+ */
436
+ CREATE OR REPLACE FUNCTION __KN__.read_anon_definer_functions(source text, role_ text DEFAULT 'anon')
437
+ RETURNS jsonb LANGUAGE plpgsql STABLE AS $$
438
+ DECLARE
439
+ writes_re constant text := '\y(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\y';
440
+ auth_re constant text := '\yauth\s*\.\s*(uid|jwt)\s*\(';
441
+ has_role boolean := EXISTS (SELECT 1 FROM pg_roles WHERE rolname = role_);
442
+ helpers jsonb;
443
+ out_ jsonb := '[]'::jsonb;
444
+ r record;
445
+ body text;
446
+ conf text;
447
+ writes boolean;
448
+ door jsonb;
449
+ BEGIN
450
+ SELECT coalesce(jsonb_agg(jsonb_build_object('name', h.name, 'nullable', h.nullable) ORDER BY h.name), '[]'::jsonb)
451
+ INTO helpers
452
+ FROM (SELECT p.proname::text AS name, p.prorettype <> 'pg_catalog.bool'::regtype AS nullable,
453
+ regexp_replace(coalesce(pg_get_functiondef(p.oid), ''), '--[^\n]*', ' ', 'g') AS b
454
+ FROM pg_proc p
455
+ JOIN pg_namespace n ON n.oid = p.pronamespace
456
+ JOIN pg_type t ON t.oid = p.prorettype
457
+ WHERE n.nspname = source AND p.prokind = 'f'
458
+ AND (p.prorettype = 'pg_catalog.bool'::regtype OR t.typtype = 'e')) h
459
+ WHERE h.b ~* auth_re AND NOT h.b ~* writes_re;
460
+
461
+ FOR r IN
462
+ SELECT p.proname::text AS name,
463
+ pg_get_function_identity_arguments(p.oid) AS args,
464
+ p.proconfig::text AS config,
465
+ pg_get_functiondef(p.oid) AS def,
466
+ p.prorettype = 'pg_catalog.bool'::regtype AS returns_bool,
467
+ (SELECT t.typtype = 'e' FROM pg_type t WHERE t.oid = p.prorettype) AS returns_enum,
468
+ CASE WHEN has_role THEN has_function_privilege(role_, p.oid, 'EXECUTE') ELSE false END AS callable
469
+ FROM pg_proc p
470
+ JOIN pg_namespace n ON n.oid = p.pronamespace
471
+ WHERE n.nspname = source
472
+ AND p.prosecdef
473
+ AND p.prorettype <> 'pg_catalog.trigger'::regtype
474
+ ORDER BY p.proname
475
+ LOOP
476
+ body := regexp_replace(coalesce(r.def, ''), '--[^\n]*', ' ', 'g');
477
+ conf := replace(replace(replace(coalesce(r.config, ''), '{', ''), '}', ''), '"', '');
478
+ writes := body ~* writes_re;
479
+ door := __KN__.door_of(body, helpers);
480
+ out_ := out_ || jsonb_build_array(jsonb_build_object(
481
+ 'name', r.name,
482
+ 'args', coalesce(r.args, ''),
483
+ 'writes', writes,
484
+ 'aboutCaller', (coalesce(r.returns_bool, false) OR coalesce(r.returns_enum, false)) AND NOT writes AND body ~* auth_re,
485
+ 'guarded', (door->>'guarded')::boolean,
486
+ 'nullSlips', (door->>'nullSlips')::boolean,
487
+ 'hasFixedSearchPath', conf ~* '(^|,)search_path=',
488
+ 'callable', coalesce(r.callable, false)));
489
+ END LOOP;
490
+ RETURN out_;
491
+ END $$;
492
+
493
+ /*
494
+ * Public buckets worth a look (scan.js bucketsOf): a read rule says only some
495
+ * people may read it, or there is no read rule and its name says its files
496
+ * are somebody's own.
497
+ */
498
+ CREATE OR REPLACE FUNCTION __KN__.bucket_findings(buckets jsonb)
499
+ RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
500
+ DECLARE
501
+ b jsonb;
502
+ rule jsonb;
503
+ esc text;
504
+ rest text;
505
+ narrowing jsonb;
506
+ no_rule boolean;
507
+ found jsonb := '[]'::jsonb;
508
+ BEGIN
509
+ FOR b IN SELECT * FROM jsonb_array_elements(coalesce(buckets, '[]'::jsonb)) LOOP
510
+ CONTINUE WHEN NOT coalesce((b->>'public')::boolean, false);
511
+ esc := regexp_replace(b->>'id', '([.*+?^${}()|\[\]\\])', '\\\1', 'g');
512
+ narrowing := '[]'::jsonb;
513
+ FOR rule IN SELECT * FROM jsonb_array_elements(coalesce(b->'readRules', '[]'::jsonb)) LOOP
514
+ rest := regexp_replace(coalesce(rule->>'qual', ''), 'bucket_id\s*=\s*''' || esc || '''(::text)?', '', 'gi');
515
+ rest := regexp_replace(rest, '''' || esc || '''(::text)?\s*=\s*bucket_id', '', 'gi');
516
+ rest := regexp_replace(rest, '\ytrue\y|\yand\y|[()\s]', '', 'gi');
517
+ IF NOT (coalesce(rule->'roles', '[]'::jsonb) ? 'anon' OR coalesce(rule->'roles', '[]'::jsonb) ? 'public')
518
+ OR rest <> '' THEN
519
+ narrowing := narrowing || to_jsonb(rule->>'name');
520
+ END IF;
521
+ END LOOP;
522
+ no_rule := jsonb_array_length(coalesce(b->'readRules', '[]'::jsonb)) = 0
523
+ AND (b->>'name') ~* '(^|[-_.])(exports?|private|documents?|docs|invoices?|contracts?|backups?|reports?|receipts?|statements?|payouts?|evidence|kyc|passports?|medical|records?|confidential|internal|secrets?|attachments?)([-_.]|$)';
524
+ IF jsonb_array_length(narrowing) > 0 OR no_rule THEN
525
+ found := found || jsonb_build_array(jsonb_build_object(
526
+ 'kind', 'bucket', 'table', b->>'name', 'bucket', b->>'id',
527
+ 'rules', narrowing, 'noRule', no_rule, 'columns', '[]'::jsonb));
528
+ END IF;
529
+ END LOOP;
530
+ RETURN found;
531
+ END $$;
532
+
370
533
  /* Everything needed to rebuild a schema, in one shape. */
371
534
  CREATE OR REPLACE FUNCTION __KN__.read_schema(source text)
372
535
  RETURNS jsonb LANGUAGE plpgsql STABLE AS $$
@@ -395,6 +558,7 @@ BEGIN
395
558
  'viewGrants', __KN__.read_view_grants(source),
396
559
  'functions', __KN__.read_functions(source),
397
560
  'buckets', __KN__.read_buckets(),
561
+ 'anonFunctions', __KN__.read_anon_definer_functions(source),
398
562
  -- A foreign key pointing at something whose shape could not be read is
399
563
  -- not something to guess at. The caller stops rather than attacking a
400
564
  -- copy that is missing a piece.
@@ -1454,6 +1618,31 @@ BEGIN
1454
1618
  RETURN keys;
1455
1619
  END $$;
1456
1620
 
1621
+ /*
1622
+ * The column that says which organization's a row is, on a table with no
1623
+ * person's name on it - or NULL. Its first single-column key to a table that
1624
+ * others point at, where the table itself is not one of those (attack.js
1625
+ * tenantKey). Found on a blind test (HarborLine): integration_secrets was
1626
+ * readable by every signed-in user, and with no person column it was never
1627
+ * tried.
1628
+ */
1629
+ CREATE OR REPLACE FUNCTION __KN__.tenant_key(tab jsonb, tables jsonb, parents jsonb)
1630
+ RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
1631
+ DECLARE
1632
+ key jsonb;
1633
+ BEGIN
1634
+ -- A view is not seeded: it shows whatever its tables hold, the second
1635
+ -- person's own organization included (workspace_health, on the same test).
1636
+ IF coalesce((tab->>'isView')::boolean, false) OR parents ? (tab->>'name') THEN RETURN NULL; END IF;
1637
+ FOR key IN SELECT * FROM jsonb_array_elements(__KN__.foreign_keys(tab) || __KN__.implied_keys(tab, tables)) LOOP
1638
+ IF key->>'refTable' <> tab->>'name' AND parents ? (key->>'refTable')
1639
+ AND jsonb_array_length(key->'columns') = 1 THEN
1640
+ RETURN key->'columns'->>0;
1641
+ END IF;
1642
+ END LOOP;
1643
+ RETURN NULL;
1644
+ END $$;
1645
+
1457
1646
  /* Every table some other table points at, by a key it declared or one it implies. */
1458
1647
  CREATE OR REPLACE FUNCTION __KN__.parent_tables(tables jsonb)
1459
1648
  RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
@@ -1942,8 +2131,19 @@ BEGIN
1942
2131
  -- into this table can use the same one instead of rediscovering it, and
1943
2132
  -- be sure a refusal is the app defending itself rather than a CHECK it
1944
2133
  -- never satisfied.
2134
+ -- A table with nobody's name on it that got a row for each person keeps
2135
+ -- which row is the first person's, so a write meant for somebody else's
2136
+ -- row is aimed at it (attack.js seed; HarborLine's invoices).
1945
2137
  seeded := seeded || jsonb_build_array(jsonb_build_object(
1946
- 'table', tab->>'name', 'owner', owner, 'attempt', worked));
2138
+ 'table', tab->>'name', 'owner', owner, 'attempt', worked)
2139
+ || CASE WHEN owner IS NULL
2140
+ AND (SELECT count(*) FROM jsonb_object_keys(rows_->(tab->>'name'))) = 2
2141
+ AND __KN__.primary_key_of(tab) IS NOT NULL
2142
+ AND rows_->(tab->>'name')->__KN__.user_a()->>__KN__.primary_key_of(tab) IS NOT NULL
2143
+ THEN jsonb_build_object('rowOfA', jsonb_build_object(
2144
+ 'column', __KN__.primary_key_of(tab),
2145
+ 'value', rows_->(tab->>'name')->__KN__.user_a()->>__KN__.primary_key_of(tab)))
2146
+ ELSE '{}'::jsonb END);
1947
2147
  ELSE
1948
2148
  -- Recorded, never swallowed. The report has to say this table was not
1949
2149
  -- checked rather than let an empty table pass for a safe one.
@@ -2093,9 +2293,13 @@ DECLARE
2093
2293
  key text;
2094
2294
  named jsonb;
2095
2295
  reached boolean;
2296
+ parents jsonb := __KN__.parent_tables(tables);
2297
+ tenant text;
2298
+ as_b jsonb;
2096
2299
  BEGIN
2097
2300
  FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
2098
2301
  owner := __KN__.owner_column(tab);
2302
+ tenant := CASE WHEN owner IS NULL THEN __KN__.tenant_key(tab, tables, parents) END;
2099
2303
  named := coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c),
2100
2304
  '[]'::jsonb);
2101
2305
 
@@ -2170,6 +2374,35 @@ BEGIN
2170
2374
  'why', 'as a signed-in customer: ' || (as_a->>'why')));
2171
2375
  END IF;
2172
2376
  END IF;
2377
+
2378
+ -- A table whose rows are an organization's, not a person's: its one row
2379
+ -- is the first person's organization's, and the second person - signed
2380
+ -- in, in an organization of their own - asks for it. What a logged-out
2381
+ -- stranger can already read is reported as exposed, and not twice.
2382
+ IF tenant IS NOT NULL THEN
2383
+ key := 'crossed:' || (tab->>'name');
2384
+ as_b := __KN__.read_as(source, tab->>'name', 'authenticated', __KN__.user_b(), NULL);
2385
+ IF (as_b->>'ok')::boolean THEN
2386
+ completed := completed || to_jsonb(key);
2387
+ IF (as_b->>'count')::bigint > 0
2388
+ AND NOT ((anon->>'ok')::boolean AND (anon->>'count')::bigint > 0) THEN
2389
+ findings := findings || jsonb_build_array(jsonb_build_object(
2390
+ 'kind', 'crossed',
2391
+ 'table', tab->>'name',
2392
+ 'owner', tenant,
2393
+ 'tenant', true,
2394
+ 'readable', (as_b->>'count')::bigint,
2395
+ 'columns', named,
2396
+ 'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
2397
+ END IF;
2398
+ ELSIF __KN__.refusal_means(as_b->>'why') = 'unreachable' THEN
2399
+ completed := completed || to_jsonb(key);
2400
+ ELSE
2401
+ blocked := blocked || jsonb_build_array(jsonb_build_object(
2402
+ 'table', tab->>'name', 'key', key,
2403
+ 'why', 'as a signed-in user in another organization: ' || (as_b->>'why')));
2404
+ END IF;
2405
+ END IF;
2173
2406
  END LOOP;
2174
2407
 
2175
2408
  RETURN jsonb_build_object('findings', findings, 'completed', completed, 'blocked', blocked,
@@ -2340,10 +2573,11 @@ DECLARE
2340
2573
  -- narrow what is allowed and never open it, so it is never the reason.
2341
2574
  rules_for jsonb;
2342
2575
  named jsonb;
2576
+ first_row jsonb;
2343
2577
  BEGIN
2344
2578
  FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
2345
2579
  -- Never seeded, so there is nothing in it to protect.
2346
- SELECT (s->>'attempt')::integer INTO shape
2580
+ SELECT (s->>'attempt')::integer, s->'rowOfA' INTO shape, first_row
2347
2581
  FROM jsonb_array_elements(coalesce(seeded, '[]'::jsonb)) s
2348
2582
  WHERE s->>'table' = tab->>'name';
2349
2583
  CONTINUE WHEN NOT FOUND;
@@ -2369,8 +2603,14 @@ BEGIN
2369
2603
  -- Rows belonging to the other fake person. Scoped on purpose: a signed-in
2370
2604
  -- customer deleting their OWN rows is not a finding, it is the feature,
2371
2605
  -- and an unscoped DELETE would report every correctly built app.
2372
- their_rows := CASE WHEN owner IS NULL THEN ''
2373
- ELSE ' WHERE ' || __KN__.always_quote(owner) || ' = ' || quote_literal(__KN__.user_a()) END;
2606
+ -- With nobody's name on the table, the first person's own row where each
2607
+ -- person got one (seed's rowOfA).
2608
+ their_rows := CASE
2609
+ WHEN owner IS NOT NULL
2610
+ THEN ' WHERE ' || __KN__.always_quote(owner) || ' = ' || quote_literal(__KN__.user_a())
2611
+ WHEN jsonb_typeof(first_row) = 'object'
2612
+ THEN ' WHERE ' || __KN__.always_quote(first_row->>'column') || '::text = ' || quote_literal(first_row->>'value')
2613
+ ELSE '' END;
2374
2614
 
2375
2615
  -- Written under a name nobody has used, which is both what makes the row
2376
2616
  -- land at all and what makes it the right test: adding a row of your own
@@ -2641,7 +2881,8 @@ DECLARE
2641
2881
  in_rule_literal text := '''\{([^}'']*,)?"?' || regexp_replace(role_, esc, '\\\1', 'g') ||
2642
2882
  '"?(,[^}'']*)?\}''::("?[A-Za-z0-9_$]+"?\.)?"?' || type_name || '"?\[\]';
2643
2883
  in_helper_list text := '(\mIN\s*\(|ARRAY\[)[^])]*' || label;
2644
- command text := CASE what WHEN 'add' THEN 'INSERT' WHEN 'change' THEN 'UPDATE' WHEN 'delete' THEN 'DELETE' END;
2884
+ -- A takeover is an UPDATE too (tamper.js COMMAND).
2885
+ command text := CASE what WHEN 'add' THEN 'INSERT' WHEN 'change' THEN 'UPDATE' WHEN 'delete' THEN 'DELETE' WHEN 'takeover' THEN 'UPDATE' END;
2645
2886
  policy jsonb;
2646
2887
  body text;
2647
2888
  BEGIN
@@ -2825,6 +3066,18 @@ BEGIN
2825
3066
  jsonb_build_object('what', 'change',
2826
3067
  'statement', 'UPDATE ' || target || ' SET ' || column_ || ' = ' || column_ || not_theirs),
2827
3068
  jsonb_build_object('what', 'delete', 'statement', 'DELETE FROM ' || target || not_theirs));
3069
+ -- On the members table itself: putting their own id on the first
3070
+ -- person's membership row - taking that member's place, and their role
3071
+ -- (tamper.js; HarborLine). Asked of every role, listed or not.
3072
+ IF tab->>'name' = member->'table'->>'name' THEN
3073
+ where_ := ARRAY[__KN__.always_quote(member->>'person') || ' = ' || quote_literal(__KN__.user_a())];
3074
+ FOR name IN SELECT jsonb_object_keys(pointed) ORDER BY 1 LOOP
3075
+ where_ := where_ || (__KN__.always_quote(name) || ' = ' || quote_literal(pointed->>name));
3076
+ END LOOP;
3077
+ moves := moves || jsonb_build_array(jsonb_build_object('what', 'takeover', 'anyRole', true,
3078
+ 'statement', 'UPDATE ' || target || ' SET ' || __KN__.always_quote(member->>'person') || ' = '
3079
+ || quote_literal(__KN__.user_b()) || ' WHERE ' || array_to_string(where_, ' AND ')));
3080
+ END IF;
2828
3081
 
2829
3082
  -- Adding a row to the team, said only of a role whose own name says it
2830
3083
  -- may only look, and written under the other person's name: a rule that
@@ -2868,7 +3121,8 @@ BEGIN
2868
3121
  CONTINUE WHEN coalesce((move->>'lookOnly')::boolean, false) AND NOT __KN__.look_only(role_);
2869
3122
  -- A write the rules list this role for is not asked: whatever it did
2870
3123
  -- would not be reported, and a key refusing it left the table untested.
2871
- IF __KN__.allowed_by_rules(rules, tab->>'name', move->>'what', role_, member->>'roleType') THEN
3124
+ IF NOT coalesce((move->>'anyRole')::boolean, false)
3125
+ AND __KN__.allowed_by_rules(rules, tab->>'name', move->>'what', role_, member->>'roleType') THEN
2872
3126
  listed := true;
2873
3127
  CONTINUE;
2874
3128
  END IF;
@@ -2903,7 +3157,10 @@ BEGIN
2903
3157
  'changed', changed,
2904
3158
  'below', below,
2905
3159
  'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c), '[]'::jsonb),
2906
- 'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
3160
+ 'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false))
3161
+ || CASE WHEN can ? 'takeover'
3162
+ THEN jsonb_build_object('takeover', true, 'personColumn', member->>'person')
3163
+ ELSE '{}'::jsonb END);
2907
3164
  EXIT;
2908
3165
  END IF;
2909
3166
  IF NOT listed THEN below := below || to_jsonb(role_); END IF;
@@ -3499,12 +3756,32 @@ BEGIN
3499
3756
  FROM jsonb_array_elements(coalesce(seen->'looped', '[]'::jsonb)) l
3500
3757
  GROUP BY l->>'relation') g), '[]'::jsonb);
3501
3758
 
3759
+ -- Functions anybody can call that run past the rules, and public buckets
3760
+ -- worth a look - read from the app's own shape, never run or opened
3761
+ -- (scan.js privilegedOf and bucketsOf). Found on a blind test
3762
+ -- (HarborLine): neither was ever in the nightly run, so the dashboard
3763
+ -- showed less than the command line did.
3764
+ findings := findings || coalesce((
3765
+ SELECT jsonb_agg(jsonb_build_object(
3766
+ 'kind', 'privileged', 'fn', f->>'name', 'table', f->>'name', 'args', f->>'args',
3767
+ 'writes', (f->>'writes')::boolean, 'hasFixedSearchPath', (f->>'hasFixedSearchPath')::boolean,
3768
+ 'nullSlips', coalesce((f->>'nullSlips')::boolean, false), 'columns', '[]'::jsonb))
3769
+ FROM jsonb_array_elements(coalesce(plan->'anonFunctions', '[]'::jsonb)) f
3770
+ WHERE (f->>'callable')::boolean AND NOT (f->>'aboutCaller')::boolean AND NOT (f->>'guarded')::boolean),
3771
+ '[]'::jsonb)
3772
+ || __KN__.bucket_findings(plan->'buckets');
3773
+
3502
3774
  -- Everything genuinely attacked, so a re-check can tell a finding that was
3503
- -- fixed from one whose attack simply did not run this time.
3775
+ -- fixed from one whose attack simply did not run this time. Every function
3776
+ -- and bucket read counts as looked at, so a revoked grant comes back fixed.
3504
3777
  attempted := coalesce(seen->'completed', '[]'::jsonb)
3505
3778
  || coalesce(wrote->'completed', '[]'::jsonb)
3506
3779
  || coalesce(ranked->'completed', '[]'::jsonb)
3507
- || coalesce(stranded->'completed', '[]'::jsonb);
3780
+ || coalesce(stranded->'completed', '[]'::jsonb)
3781
+ || coalesce((SELECT jsonb_agg('privileged:' || (f->>'name'))
3782
+ FROM jsonb_array_elements(coalesce(plan->'anonFunctions', '[]'::jsonb)) f), '[]'::jsonb)
3783
+ || coalesce((SELECT jsonb_agg('bucket:' || (b->>'name'))
3784
+ FROM jsonb_array_elements(coalesce(plan->'buckets', '[]'::jsonb)) b), '[]'::jsonb);
3508
3785
 
3509
3786
  -- And everything that was not. Said out loud, never left out: a table
3510
3787
  -- nothing could be put into reads exactly like a table nothing got out of.
package/finding.js CHANGED
@@ -358,6 +358,10 @@ function headlineFor(finding) {
358
358
  if (finding.kind === 'privileged') {
359
359
  return 'Anyone on the internet can call your ' + finding.fn + ' function, and it runs with full rights.';
360
360
  }
361
+ if (finding.kind === 'role' && finding.takeover) {
362
+ return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can take another member\'s place in your ' +
363
+ finding.table + ' table - and with it their role.';
364
+ }
361
365
  if (finding.kind === 'role') {
362
366
  const order = ['delete', 'change', 'add'];
363
367
  const does = order.filter((what) => (finding.can || []).includes(what)).map((what) => WRITE_WORDS[what]);
@@ -368,6 +372,9 @@ function headlineFor(finding) {
368
372
  return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can read your ' + finding.table +
369
373
  ' table, which holds ' + listOf(finding.secrets || []) + '.';
370
374
  }
375
+ if (finding.kind === 'bucket' && finding.noRule) {
376
+ return 'Your ' + finding.table + ' storage bucket is public, and its name says its files are not for everyone.';
377
+ }
371
378
  if (finding.kind === 'bucket') {
372
379
  return 'Your ' + finding.table + ' storage bucket is public, but a rule says only some people may read it.';
373
380
  }
@@ -377,6 +384,7 @@ function headlineFor(finding) {
377
384
  // The table name goes in front of the sentence rather than inside it. Put
378
385
  // inside, a table called `customers` produced "One customer can read another
379
386
  // customer's customers", which is the kind of line that loses a reader.
387
+ if (finding.tenant) return 'Your ' + finding.table + ' table lets a signed-in user read another organization\'s rows.';
380
388
  return 'Your ' + finding.table + ' table lets one customer read another one\'s rows.';
381
389
  }
382
390
 
@@ -454,6 +462,20 @@ function bodyFor(finding, contents) {
454
462
  'kept, and your live app was never touched.';
455
463
  }
456
464
 
465
+ if (finding.kind === 'role' && finding.takeover) {
466
+ // Not a matter of taste like a viewer who may edit: taking somebody
467
+ // else's membership is never what a role is for.
468
+ const place = oneOf(finding.parent);
469
+ return (
470
+ 'I put one of my fake people into the other one\'s ' + place + ' as ' + aRole(finding.who) +
471
+ ' in ' + finding.via + '.' + finding.roleColumn + ' and, signed in as them, changed ' +
472
+ finding.via + '.' + finding.personColumn + ' on the other person\'s own membership row to their own id - so ' +
473
+ 'they held that member\'s place and role, on a copy of your app. Before joining, they could not. It was ' +
474
+ 'undone straight away. A member taking over another member\'s row is never something a role should be able ' +
475
+ 'to do, even where members may edit some of a membership.'
476
+ );
477
+ }
478
+
457
479
  if (finding.kind === 'role') {
458
480
  // Said as what was done, and then whose decision it is: a viewer who may
459
481
  // edit is a hole in one app and the design of another.
@@ -494,6 +516,15 @@ function bodyFor(finding, contents) {
494
516
  );
495
517
  }
496
518
 
519
+ if (finding.kind === 'bucket' && finding.noRule) {
520
+ return (
521
+ 'In Supabase Storage the bucket "' + finding.bucket + '" is set to public, so anyone with a ' +
522
+ 'file\'s link can download it without logging in - the rules on storage.objects are not asked. ' +
523
+ 'No rule says who may read it, but its name says what it holds, and that does not sound like ' +
524
+ 'something for everyone. I only read the bucket\'s setting and its rules; I did not open or list ' +
525
+ 'any file. Whether it should be public is yours to say.'
526
+ );
527
+ }
497
528
  if (finding.kind === 'bucket') {
498
529
  // Said as what was read, never as a file taken: no file was opened.
499
530
  return (
@@ -520,6 +551,11 @@ function bodyFor(finding, contents) {
520
551
  : ', reaching whatever it reads past the rules on those tables.') +
521
552
  ' I did not call it: whether an open function like this is intended is ' +
522
553
  'something only you can confirm.' +
554
+ (finding.nullSlips
555
+ ? ' Its first lines do check the caller\'s role - but with NOT IN or <>, and somebody with no role ' +
556
+ 'there gets NULL back, and NULL NOT IN (...) is not true: the IF never raises, so a signed-in ' +
557
+ 'stranger gets through as if they had passed.'
558
+ : '') +
523
559
  (finding.hasFixedSearchPath ? ''
524
560
  : ' It also does not pin its search_path, so a caller can point the names ' +
525
561
  'inside it at their own objects.')
@@ -558,6 +594,13 @@ function bodyFor(finding, contents) {
558
594
  (holds ? ', including ' + holds : '') + '.'
559
595
  );
560
596
  }
597
+ if (finding.tenant) {
598
+ return (
599
+ 'Signed in as someone in a different organization, I asked for another organization\'s rows and got ' +
600
+ finding.readable + ' of them back' + (holds ? ', including ' + holds : '') +
601
+ '. Anyone with an account can read every organization\'s rows.'
602
+ );
603
+ }
561
604
  return (
562
605
  'Signed in as one customer, I asked for another customer\'s rows and got ' +
563
606
  finding.readable + ' of them back' + (holds ? ', including ' + holds : '') +
@@ -629,14 +672,17 @@ function serverOnlyLines(finding) {
629
672
  // column saying whose row it is. The assistant applying it had to correct
630
673
  // the prompt; a less careful one would have locked the table away from the
631
674
  // members who use it.
632
- const team = finding.authTied !== false && (finding.columns || []).find((c) => TENANT_COLUMN.test(String(c)));
675
+ // A row an organization owns names its organization even when the column is
676
+ // not called organization_id: the attack found which key it is.
677
+ const team = finding.authTied !== false &&
678
+ (finding.tenant ? finding.owner : (finding.columns || []).find((c) => TENANT_COLUMN.test(String(c))));
633
679
  const maker = finding.owner || finding.ownedBy;
634
680
  // A team's row that also says who made it - created_by beside workspace_id.
635
681
  // Found on a benchmark: the prompt said to compare created_by to auth.uid()
636
682
  // for reading, which hides every teammate's rows and breaks the shared
637
683
  // view the table exists for. Who made a row decides who may write it
638
684
  // under their name, not who may see it.
639
- if (team && finding.owned !== false && maker) {
685
+ if (team && !finding.tenant && finding.owned !== false && maker) {
640
686
  return [
641
687
  'Rows in "' + table + '" belong to a team ("' + team + '"), and "' + maker + '" only records who ' +
642
688
  'made each one. So reading should be decided by team membership: let a signed-in user read a ' +
@@ -654,7 +700,7 @@ function serverOnlyLines(finding) {
654
700
  '',
655
701
  ];
656
702
  }
657
- const tenant = finding.owned === false && team;
703
+ const tenant = (finding.tenant || finding.owned === false) && team;
658
704
  if (tenant) {
659
705
  return [
660
706
  'Rows in "' + table + '" belong to an organization ("' + tenant + '"), not to one person. So the ' +
@@ -785,6 +831,23 @@ function fixPromptFor(finding) {
785
831
  'Then sign in as an ordinary user and open the pages that use these tables, and run this ' +
786
832
  'check again - the tables it could not test will be tested this time.',
787
833
  ]
834
+ : finding.kind === 'role' && finding.takeover
835
+ ? [
836
+ 'My app has a permissions problem - a member can take over another member\'s place.',
837
+ '',
838
+ 'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
839
+ finding.roleColumn + '" is "' + finding.who + '" can update another member\'s row in "' + finding.via +
840
+ '" and set "' + finding.personColumn + '" to their own id - taking that member\'s place and role, an ' +
841
+ 'admin\'s or the owner\'s included. This was proved on a copy of the database.',
842
+ '',
843
+ 'Fix the rules on "' + finding.via + '" that cover UPDATE or ALL: nobody may change "' + finding.personColumn +
844
+ '" or the team a membership belongs to on an existing row - adding or removing members is how membership ' +
845
+ 'changes. Let only the roles that manage the team (usually owner and admin) update membership rows at ' +
846
+ 'all, check that in WITH CHECK as well as USING, and never use WITH CHECK (true). If members only need to ' +
847
+ 'change something of their own, allow exactly that column on their own row and nothing else.',
848
+ '',
849
+ 'Then check every function that updates "' + finding.via + '" for the same thing.',
850
+ ]
788
851
  : finding.kind === 'role'
789
852
  ? [
790
853
  'My app may have a permissions problem - please check it rather than assume it.',
@@ -830,11 +893,14 @@ function fixPromptFor(finding) {
830
893
  'My app may have a storage problem - please check it rather than assume it.',
831
894
  '',
832
895
  'The Supabase Storage bucket "' + finding.bucket + '" is public. Anyone with a file\'s URL can ' +
833
- 'download it without logging in, and the storage.objects ' +
834
- ((finding.rules || []).length === 1 ? 'policy that names' : 'policies that name') + ' this bucket (' +
835
- listOf((finding.rules || []).map((r) => '"' + r + '"')) + ') ' +
836
- ((finding.rules || []).length === 1 ? 'is' : 'are') + ' not checked for public URLs - so ' +
837
- ((finding.rules || []).length === 1 ? 'it protects' : 'they protect') + ' nothing while it is public.',
896
+ 'download it without logging in' + (finding.noRule
897
+ ? ', and no rule on storage.objects says who may read it - its name suggests its files are not ' +
898
+ 'meant for everyone.'
899
+ : ', and the storage.objects ' +
900
+ ((finding.rules || []).length === 1 ? 'policy that names' : 'policies that name') + ' this bucket (' +
901
+ listOf((finding.rules || []).map((r) => '"' + r + '"')) + ') ' +
902
+ ((finding.rules || []).length === 1 ? 'is' : 'are') + ' not checked for public URLs - so ' +
903
+ ((finding.rules || []).length === 1 ? 'it protects' : 'they protect') + ' nothing while it is public.'),
838
904
  '',
839
905
  'First tell me: is every file in this bucket meant to be downloadable by anyone? If yes, change nothing.',
840
906
  '',
@@ -843,8 +909,11 @@ function fixPromptFor(finding) {
843
909
  'change the app to fetch these files with signed URLs (createSignedUrl) instead of getPublicUrl, ' +
844
910
  'or the links it shows will stop working.',
845
911
  '',
846
- 'Keep the read rule: once the bucket is private it is what decides who gets a file. Check that it ' +
847
- 'only lets in the right people.',
912
+ finding.noRule
913
+ ? 'Once it is private, add a read rule on storage.objects for this bucket that lets in only the ' +
914
+ 'people a file belongs to - without one, nobody but your server can read them.'
915
+ : 'Keep the read rule: once the bucket is private it is what decides who gets a file. Check that it ' +
916
+ 'only lets in the right people.',
848
917
  '',
849
918
  'Then look at every other bucket for the same thing.',
850
919
  ]
@@ -863,6 +932,16 @@ function fixPromptFor(finding) {
863
932
  'backend key, the way an admin-only function like this should be reached.',
864
933
  '',
865
934
  ].concat(
935
+ finding.nullSlips
936
+ ? [
937
+ 'Its role check lets through anyone with no role at all: the helper returns NULL for them, and ' +
938
+ 'NULL NOT IN (...) - or NULL <> ... - is not true, so the IF never raises. Write the check so ' +
939
+ 'NULL cannot pass, for example IF coalesce(<the helper>(...), \'none\') NOT IN (...) THEN RAISE, ' +
940
+ 'or IF <the helper>(...) IS NULL OR <the helper>(...) NOT IN (...) THEN RAISE - and check every ' +
941
+ 'other function that uses the same helper the same way.',
942
+ '',
943
+ ]
944
+ : [],
866
945
  finding.hasFixedSearchPath
867
946
  ? []
868
947
  : [
@@ -998,7 +1077,9 @@ function fixPromptFor(finding) {
998
1077
  'The "' + finding.table + '" table ' +
999
1078
  (finding.kind === 'exposed'
1000
1079
  ? 'can be read by anyone who is not logged in, because ' + cause.short + '.'
1001
- : 'lets one signed-in user read rows belonging to a different user, because ' + cause.short + '.'),
1080
+ : finding.tenant
1081
+ ? 'lets a signed-in user read rows belonging to a different organization, because ' + cause.short + '.'
1082
+ : 'lets one signed-in user read rows belonging to a different user, because ' + cause.short + '.'),
1002
1083
  '',
1003
1084
  ].concat(
1004
1085
  serverOnlyLines(finding) || [
@@ -1058,11 +1139,22 @@ function describe(finding) {
1058
1139
  readable: finding.readable,
1059
1140
  secrets: finding.secrets,
1060
1141
  bucket: finding.bucket,
1142
+ // An organization's rows read from outside it, and a member taking over
1143
+ // another member's row: kept so the re-check and the read-back word them
1144
+ // the same way the report did.
1145
+ tenant: finding.tenant,
1146
+ takeover: finding.takeover,
1147
+ personColumn: finding.personColumn,
1148
+ nullSlips: finding.nullSlips,
1149
+ noRule: finding.noRule,
1061
1150
  proof: finding.kind === 'recursive'
1062
1151
  ? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
1063
1152
  loopError(finding) + '.'
1064
1153
  : finding.kind === 'privileged'
1065
1154
  ? 'I did not call "' + finding.fn + '". This is a reach that exists in the grants, not a break I ran.'
1155
+ : finding.kind === 'role' && finding.takeover
1156
+ ? 'As ' + aRole(finding.who) + ' who had just joined, I put my own id on the other person\'s row of "' +
1157
+ finding.via + '", which I could not touch before joining. It was undone.'
1066
1158
  : finding.kind === 'role'
1067
1159
  ? 'As ' + aRole(finding.who) + ' who had just joined, I ' +
1068
1160
  listOf((finding.can || []).map((what) => {
@@ -1072,6 +1164,8 @@ function describe(finding) {
1072
1164
  : finding.kind === 'teamread'
1073
1165
  ? 'As ' + aRole(finding.who) + ' who had just joined, I read ' + (finding.readable || 1) +
1074
1166
  ((finding.readable || 1) === 1 ? ' row' : ' rows') + ' of "' + finding.table + '" that I could not read before joining.'
1167
+ : finding.kind === 'bucket' && finding.noRule
1168
+ ? 'I read that "' + finding.bucket + '" is public and that no rule says who may read it. I did not open any file.'
1075
1169
  : finding.kind === 'bucket'
1076
1170
  ? 'I read that "' + finding.bucket + '" is public and that ' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
1077
1171
  ' says who may read it. I did not open any file.'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon-night",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
4
4
  "description": "Attacks a copy of your Supabase database and tells you in plain English what got in.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -31,7 +31,7 @@
31
31
  "node": ">=18.0.0"
32
32
  },
33
33
  "scripts": {
34
- "check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node recursion.check.js && node guarded.check.js && node nightly.check.js && node words.check.js && node helpers.check.js && node deeploop.check.js && node teams.check.js && node roles.check.js && node buckets.check.js && node cli.check.js && node loop.check.js && node report.check.js && node installscript.check.js",
34
+ "check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node recursion.check.js && node guarded.check.js && node nightly.check.js && node words.check.js && node helpers.check.js && node deeploop.check.js && node teams.check.js && node roles.check.js && node buckets.check.js && node cli.check.js && node loop.check.js && node report.check.js && node installscript.check.js && node tenants.check.js && node takeover.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",
@@ -75,7 +75,9 @@
75
75
  "check:installer": "node installer.check.js",
76
76
  "check:roles": "node roles.check.js",
77
77
  "check:buckets": "node buckets.check.js",
78
- "check:installscript": "node installscript.check.js"
78
+ "check:installscript": "node installscript.check.js",
79
+ "check:tenants": "node tenants.check.js",
80
+ "check:takeover": "node takeover.check.js"
79
81
  },
80
82
  "keywords": [
81
83
  "supabase",
package/scan.js CHANGED
@@ -505,6 +505,8 @@ function privilegedOf(plan) {
505
505
  args: fn.args,
506
506
  writes: fn.writes,
507
507
  hasFixedSearchPath: fn.hasFixedSearchPath,
508
+ // Its only check at the door is one a caller with no role slips past.
509
+ nullSlips: fn.nullSlips === true,
508
510
  columns: [],
509
511
  }));
510
512
  }
@@ -536,10 +538,19 @@ function bucketsOf(plan) {
536
538
  table: b.name,
537
539
  bucket: b.id,
538
540
  rules: (b.readRules || []).filter((rule) => narrows(rule, b.id)).map((rule) => rule.name),
541
+ // No read rule at all, and a name that says what is in it is not for
542
+ // everyone. Found on a blind test (HarborLine): customer-exports was
543
+ // public, and its one read rule had failed to load (a text compared to a
544
+ // uuid) - with nothing to read, the bucket was never mentioned.
545
+ noRule: !(b.readRules || []).length && PRIVATE_BUCKET.test(String(b.name)),
539
546
  columns: [],
540
- })).filter((f) => f.rules.length);
547
+ })).filter((f) => f.rules.length || f.noRule);
541
548
  }
542
549
 
550
+ // Names that say a bucket's files are somebody's own business. Whole words
551
+ // only: "plain" or "avatars" say nothing, and stay quiet.
552
+ const PRIVATE_BUCKET = /(^|[-_.])(exports?|private|documents?|docs|invoices?|contracts?|backups?|reports?|receipts?|statements?|payouts?|evidence|kyc|passports?|medical|records?|confidential|internal|secrets?|attachments?)([-_.]|$)/i;
553
+
543
554
  /**
544
555
  * What each finding needs to know about its table for its fix to be right:
545
556
  * whether the app signs people in through Supabase, whether a row says whose
@@ -619,14 +630,17 @@ function nightlyRecord(latest, readAt, plan) {
619
630
  if (!run) return record;
620
631
  // The night's findings, told as the night's: tagged with what their table
621
632
  // needs for a right fix when the schema could be read, and joined by the
622
- // functions anyone can call - read now, when this is asked, because the
623
- // engine inside the database does not look at them.
633
+ // functions anyone can call and the public buckets - read now, when this is
634
+ // asked, which is newer than the night. The night records them too since
635
+ // 0.1.32 (for the dashboard), so where they can be read now the night's own
636
+ // are left out rather than said twice; where they cannot, the night's stand.
624
637
  const tag = plan ? taggerFor(plan, plan.tables) : (f) => f;
625
638
  // A loop the engine could not name ("stack depth limit exceeded") is put
626
639
  // on the tables whose own rule reads back into them, read from the shape.
627
640
  const loops = plan ? loopingTables(plan) : [];
628
641
  const night = [];
629
642
  for (const f of run.findings || []) {
643
+ if (plan && (f.kind === 'privileged' || f.kind === 'bucket')) continue;
630
644
  const at = f.kind === 'recursive' && !f.table ? (loops.length ? loops : ['one of your tables']) : [f.table];
631
645
  for (const table of at) night.push(tag(Object.assign({}, f, { table: table, fromNight: true })));
632
646
  }
package/schema.js CHANGED
@@ -225,6 +225,25 @@ async function readIndexes(client, schema) {
225
225
  * argument the caller chose.
226
226
  */
227
227
  function guardedAtTheDoor(body, helpers) {
228
+ return doorOf(body, helpers).guarded;
229
+ }
230
+
231
+ /**
232
+ * The same reading, and one more answer: whether the only check at the door
233
+ * is one a caller with no role at all slips past.
234
+ *
235
+ * Found on a blind test (HarborLine): complete_automation_run opened with
236
+ * if public.org_role(v_org) not in ('owner', 'admin') then raise ...
237
+ * org_role answers with the caller's role there - and with NULL for somebody
238
+ * who has none. NULL NOT IN (...) is not true, so the IF never raises, and a
239
+ * stranger completed another organization's runs. A role helper's check is a
240
+ * guard only when it is written so NULL cannot pass - coalesce(...), or
241
+ * IS NULL OR ... - and otherwise it is said, by name, why it is not one.
242
+ *
243
+ * `helpers` are names (yes/no helpers about the caller) or { name, nullable }
244
+ * (a role helper, which answers NULL for a stranger).
245
+ */
246
+ function doorOf(body, helpers) {
228
247
  const text = String(body || '');
229
248
  const writes = /\b(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\b/i.exec(text);
230
249
  const returns = /\breturn\b/i.exec(text);
@@ -236,17 +255,26 @@ function guardedAtTheDoor(body, helpers) {
236
255
  // never as the tail of a longer name.
237
256
  const asks = (cond, name) => new RegExp('(^|[^\\w$"])"?' +
238
257
  name.replace(/[$]/g, '\\$') + '"?\\s*\\(', 'i').test(cond);
258
+ const list = (helpers || []).map((h) => (typeof h === 'string' ? { name: h, nullable: false } : h));
259
+ let slips = false;
239
260
  let found;
240
261
  while ((found = guard.exec(text)) !== null) {
241
262
  if (found.index > firstAct) break;
242
263
  const cond = found[1].trim();
243
264
  // Refusing when the check fails, not when it passes.
244
265
  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;
266
+ if (refusesOnNo && list.some((h) => !h.nullable && asks(cond, h.name))) return { guarded: true, nullSlips: false };
267
+ if (refusesOnNo && /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(cond) && /admin|owner|member|role/i.test(cond)) {
268
+ return { guarded: true, nullSlips: false };
269
+ }
270
+ if (list.some((h) => h.nullable && asks(cond, h.name))) {
271
+ const refuses = refusesOnNo || /\bnot\s+in\s*\(/i.test(cond) || /<>|!=/.test(cond);
272
+ const nullSafe = /\bcoalesce\s*\(/i.test(cond) || /\bis\s+null\b/i.test(cond) || /\bis\s+not\s+true\s*$/i.test(cond);
273
+ if (refuses && nullSafe) return { guarded: true, nullSlips: false };
274
+ if (refuses) slips = true;
275
+ }
248
276
  }
249
- return false;
277
+ return { guarded: false, nullSlips: slips };
250
278
  }
251
279
 
252
280
  async function readAnonDefinerFunctions(client, schema, role) {
@@ -262,6 +290,7 @@ async function readAnonDefinerFunctions(client, schema, role) {
262
290
  p.proconfig::text AS config,
263
291
  pg_get_functiondef(p.oid) AS def,
264
292
  p.prorettype = 'pg_catalog.bool'::regtype AS returns_bool,
293
+ (SELECT t.typtype = 'e' FROM pg_type t WHERE t.oid = p.prorettype) AS returns_enum,
265
294
  ${exists.rows.length ? "has_function_privilege($2, p.oid, 'EXECUTE')" : 'false'} AS callable
266
295
  FROM pg_proc p
267
296
  JOIN pg_namespace n ON n.oid = p.pronamespace
@@ -271,21 +300,25 @@ async function readAnonDefinerFunctions(client, schema, role) {
271
300
  ORDER BY p.proname`,
272
301
  exists.rows.length ? [schema, caller] : [schema],
273
302
  );
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.
303
+ // The schema's helpers about the caller - is_org_admin(org) asking whether
304
+ // auth.uid() is an admin of it, org_role(org) answering with their role -
305
+ // definer or not, since a guard can call either. Read once, for doorOf. A
306
+ // role helper answers NULL for somebody with no role, so it is marked.
277
307
  const { rows: yesNo } = await client.query(
278
- `SELECT p.proname AS name, pg_get_functiondef(p.oid) AS def
308
+ `SELECT p.proname AS name, pg_get_functiondef(p.oid) AS def,
309
+ p.prorettype <> 'pg_catalog.bool'::regtype AS nullable
279
310
  FROM pg_proc p
280
311
  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`,
312
+ JOIN pg_type t ON t.oid = p.prorettype
313
+ WHERE n.nspname = $1 AND p.prokind = 'f' AND (p.prorettype = 'pg_catalog.bool'::regtype OR t.typtype = 'e')
314
+ ORDER BY p.proname`,
282
315
  [schema],
283
316
  );
284
317
  const helpers = yesNo.filter((row) => {
285
318
  const body = String(row.def || '').replace(/--[^\n]*/g, ' ');
286
319
  return /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(body) &&
287
320
  !/\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);
321
+ }).map((row) => ({ name: row.name, nullable: row.nullable === true }));
289
322
  return rows.map((row) => {
290
323
  // A definer function with no search_path pinned is a second, separate
291
324
  // hazard: the caller can set their own search_path and make the function
@@ -309,13 +342,21 @@ async function readAnonDefinerFunctions(client, schema, role) {
309
342
  // else and writing nothing, they are not a reach. A yes/no that does not
310
343
  // ask auth.uid() - is_admin(user_id) - answers about other people, and
311
344
  // stays a finding.
312
- const aboutCaller = row.returns_bool === true && !writes && /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(body);
345
+ //
346
+ // A role about the caller alone is the same: org_role(org), the caller's
347
+ // own role there, from a fixed list of labels (an enum) that can hold
348
+ // nothing about anybody else. Found on a blind test (HarborLine), where
349
+ // org_role was reported beside the functions it guards.
350
+ const aboutCaller = (row.returns_bool === true || row.returns_enum === true) && !writes &&
351
+ /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(body);
352
+ const door = doorOf(body, helpers);
313
353
  return {
314
354
  name: row.name,
315
355
  args: row.args || '',
316
356
  writes: writes,
317
357
  aboutCaller: aboutCaller,
318
- guarded: guardedAtTheDoor(body, helpers),
358
+ guarded: door.guarded,
359
+ nullSlips: door.nullSlips,
319
360
  hasFixedSearchPath: hasFixedSearchPath,
320
361
  callable: row.callable === true,
321
362
  };
@@ -1438,6 +1479,7 @@ module.exports = {
1438
1479
  readSchema: readSchema,
1439
1480
  readAnonDefinerFunctions: readAnonDefinerFunctions,
1440
1481
  guardedAtTheDoor: guardedAtTheDoor,
1482
+ doorOf: doorOf,
1441
1483
  writeSchema: writeSchema,
1442
1484
  diffSchemas: diffSchemas,
1443
1485
  readPolicies: readPolicies,
package/tamper.js CHANGED
@@ -114,6 +114,11 @@ async function tamper(client, schema, tables, seeded, policies) {
114
114
  rulesFor.set(policy.table_name, list);
115
115
  }
116
116
 
117
+ // The first person's row in tables that hold one for each person but name
118
+ // nobody (attack.seed), so a write is aimed at a row that is not the
119
+ // caller's own.
120
+ const firstRowOf = new Map((seeded || []).filter((entry) => entry.rowOfA).map((entry) => [entry.table, entry.rowOfA]));
121
+
117
122
  for (const table of tables) {
118
123
  if (!shapeFor.has(table.name)) continue; // never seeded, so nothing to protect
119
124
  const owner = attack.ownerColumn(table);
@@ -127,9 +132,12 @@ async function tamper(client, schema, tables, seeded, policies) {
127
132
  // Rows belonging to the other fake person. Scoped on purpose: a signed-in
128
133
  // customer deleting their OWN rows is not a finding, it is the feature,
129
134
  // and an unscoped DELETE would report every correctly built app.
135
+ const firstRow = firstRowOf.get(table.name);
130
136
  const theirRows = owner
131
137
  ? ' WHERE ' + quote(owner) + ' = ' + "'" + attack.USER_A + "'"
132
- : '';
138
+ : firstRow
139
+ ? ' WHERE ' + quote(firstRow.column) + "::text = '" + firstRow.value.split("'").join("''") + "'"
140
+ : '';
133
141
  const at = quote(schema) + '.' + quote(table.name);
134
142
 
135
143
  // Written under a name nobody has used, which is both what makes the
@@ -334,7 +342,8 @@ function memberships(tables) {
334
342
  }
335
343
 
336
344
  // What each kind of write is called in a rule.
337
- const COMMAND = { add: 'INSERT', change: 'UPDATE', delete: 'DELETE' };
345
+ // A takeover is an UPDATE too: a rule for updating is what it would hide behind.
346
+ const COMMAND = { add: 'INSERT', change: 'UPDATE', delete: 'DELETE', takeover: 'UPDATE' };
338
347
 
339
348
  /**
340
349
  * Whether the app's own rules name this role among those allowed to make
@@ -478,6 +487,22 @@ async function teammate(client, schema, tables, seeded, rules) {
478
487
  { what: 'change', statement: 'UPDATE ' + target + ' SET ' + column + ' = ' + column + notTheirs, values: [] },
479
488
  { what: 'delete', statement: 'DELETE FROM ' + target + notTheirs, values: [] },
480
489
  ];
490
+ // On the members table itself: putting their own id on the first
491
+ // person's membership row - taking that member's place, and their role.
492
+ // Found on a blind test (HarborLine): a member could update any member
493
+ // row of the team, and repointing an admin's row made them admin. A rule
494
+ // that lets a role edit memberships does not make this the design, so
495
+ // it is asked of every role, listed or not.
496
+ if (table.name === member.table.name) {
497
+ const firstPersons = [quote(member.person) + " = '" + attack.USER_A + "'"]
498
+ .concat(Object.keys(pointed).map((name) => quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'"));
499
+ moves.push({
500
+ what: 'takeover',
501
+ anyRole: true,
502
+ statement: 'UPDATE ' + target + ' SET ' + quote(member.person) + " = '" + attack.USER_B + "' WHERE " + firstPersons.join(' AND '),
503
+ values: [],
504
+ });
505
+ }
481
506
  // Adding a row to the team, said only of a role whose own name says it
482
507
  // may only look: a member adding rows is the feature, a viewer adding
483
508
  // them is not. Found on a blind test (HelixOps): any member could add
@@ -534,7 +559,7 @@ async function teammate(client, schema, tables, seeded, rules) {
534
559
  // A write the rules list this role for is not asked: whatever it
535
560
  // did would not be reported, and a key refusing it on the way out
536
561
  // left the table untested (HelixOps' assets, as a dispatcher).
537
- if (allowedByRules(rules, table.name, move.what, role, member.roleType)) {
562
+ if (!move.anyRole && allowedByRules(rules, table.name, move.what, role, member.roleType)) {
538
563
  listed = true;
539
564
  continue;
540
565
  }
@@ -554,7 +579,7 @@ async function teammate(client, schema, tables, seeded, rules) {
554
579
  }
555
580
  completed.push(key);
556
581
  if (can.length) {
557
- findings.push({
582
+ findings.push(Object.assign({
558
583
  kind: 'role',
559
584
  table: table.name,
560
585
  who: role,
@@ -566,7 +591,7 @@ async function teammate(client, schema, tables, seeded, rules) {
566
591
  below: below.slice(),
567
592
  columns: (table.columns || []).map((c) => c.name),
568
593
  rlsEnabled: table.rlsEnabled,
569
- });
594
+ }, can.includes('takeover') ? { takeover: true, personColumn: member.person } : {}));
570
595
  break;
571
596
  }
572
597
  if (!listed) below.push(role);