kryptheon-night 0.1.30 → 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/README.md CHANGED
@@ -167,6 +167,14 @@ properly.
167
167
  `uninstall` removes the job, the schema, and any extension it had to add, and
168
168
  leaves alone anything that was already there.
169
169
 
170
+ **No terminal at all?** Your dashboard on kryptheon.tech gives you the same
171
+ install as one SQL script: paste it into your Supabase SQL editor and press
172
+ Run. It leaves your database exactly as `npx kryptheon-night install` does —
173
+ `installscript.check.js` installs both ways side by side and compares every
174
+ function, the job, the token and the record uninstall reads — and the matching
175
+ uninstall script takes it back out, whichever way it went in. From code:
176
+ `require('kryptheon-night/installer.js').installScript({ source, reportTo })`.
177
+
170
178
  ## Three answers, three exit codes
171
179
 
172
180
  | exit | meaning |
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.