kryptheon-night 0.1.31 → 0.1.33

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/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.
@@ -1300,6 +1464,155 @@ BEGIN
1300
1464
  RETURN found;
1301
1465
  END $$;
1302
1466
 
1467
+ /*
1468
+ * The CHECK constraints on a table that name this column, as the expression
1469
+ * inside CHECK (...). A NOT VALID one still applies to every new row.
1470
+ * Mirrors checksOn in attack.js.
1471
+ */
1472
+ CREATE OR REPLACE FUNCTION __KN__.checks_on(tab jsonb, column_name text)
1473
+ RETURNS text[] LANGUAGE plpgsql IMMUTABLE AS $$
1474
+ DECLARE
1475
+ escaped text := regexp_replace(column_name, '([.*+?^${}()|\[\]\\])', '\\\1', 'g');
1476
+ named text;
1477
+ found text[] := ARRAY[]::text[];
1478
+ con jsonb;
1479
+ definition text;
1480
+ BEGIN
1481
+ named := '(^|[^A-Za-z0-9_$"])("' || replace(escaped, '"', '""') || '"|' || escaped || ')($|[^A-Za-z0-9_$"])';
1482
+ FOR con IN SELECT * FROM jsonb_array_elements(coalesce(tab->'constraints', '[]'::jsonb)) LOOP
1483
+ CONTINUE WHEN con->>'kind' <> 'c';
1484
+ definition := coalesce(con->>'definition', '');
1485
+ CONTINUE WHEN definition !~ named;
1486
+ found := found || regexp_replace(regexp_replace(definition, '^CHECK\s*', '', 'i'), '\s+NOT VALID\s*$', '', 'i');
1487
+ END LOOP;
1488
+ RETURN found;
1489
+ END $$;
1490
+
1491
+ /*
1492
+ * Values to offer a column whose CHECK could not be read, best first.
1493
+ *
1494
+ * Found on a real app (a clinic's, 2026-10-11): phone number columns with a
1495
+ * CHECK on their format, and a type column with a CHECK nothing here read -
1496
+ * three tables, and a fourth that pointed at one of them, never tested.
1497
+ * Nothing here reads the rule: each value is put to the rule itself
1498
+ * (fitting_values), and the first it accepts is used.
1499
+ *
1500
+ * Mirrors checkCandidates in attack.js, value for value and in the same
1501
+ * order: both engines must seed the same rows.
1502
+ */
1503
+ CREATE OR REPLACE FUNCTION __KN__.check_candidates(col jsonb, definitions text[], distinct_ text)
1504
+ RETURNS text[] LANGUAGE plpgsql IMMUTABLE AS $$
1505
+ DECLARE
1506
+ kind text := lower(coalesce(col->>'base_type', col->>'type'));
1507
+ tag text := coalesce(distinct_, '');
1508
+ step integer := CASE WHEN coalesce(distinct_, '') ~ '^[0-9]+$' THEN distinct_::integer ELSE 0 END;
1509
+ d text;
1510
+ name_ text := lower(col->>'name');
1511
+ out_ text[] := ARRAY[]::text[];
1512
+ phones text[];
1513
+ emails text[];
1514
+ links text[];
1515
+ words text[];
1516
+ literals text[] := ARRAY[]::text[];
1517
+ codes text[];
1518
+ first_ text[] := ARRAY[]::text[];
1519
+ definition text;
1520
+ one text;
1521
+ value_ text;
1522
+ BEGIN
1523
+ IF kind ~ '^(integer|bigint|smallint|numeric|decimal|real|double)' THEN
1524
+ FOREACH value_ IN ARRAY ARRAY[(1 + step)::text, (18 + step)::text, (100 + step)::text, (1000 + step)::text, '0', '5'] LOOP
1525
+ IF NOT (value_ = ANY (out_)) THEN out_ := out_ || value_; END IF;
1526
+ END LOOP;
1527
+ FOREACH definition IN ARRAY coalesce(definitions, ARRAY[]::text[]) LOOP
1528
+ FOR one IN SELECT (m)[1] FROM regexp_matches(definition, '(-?\d+(?:\.\d+)?)', 'g') m LOOP
1529
+ IF NOT (one = ANY (out_)) THEN out_ := out_ || one; END IF;
1530
+ END LOOP;
1531
+ END LOOP;
1532
+ RETURN out_;
1533
+ END IF;
1534
+ IF kind !~ '^(text|character|varchar|citext)' THEN RETURN out_; END IF;
1535
+ d := (step % 10)::text;
1536
+ phones := ARRAY['+1415555012' || d, '+919876543' || (210 + step)::text, '9876543' || (210 + step)::text, '415555012' || d,
1537
+ '+1 415-555-012' || d, '(415) 555-012' || d, '415-555-012' || d, '+44 7700 90012' || d, '07700 90012' || d];
1538
+ emails := ARRAY['kryptheon' || tag || '@example.com'];
1539
+ links := ARRAY['https://example.com/kryptheon' || tag];
1540
+ -- The shapes value_for offers, in its order, so a rule they already passed
1541
+ -- still gets the value it always got.
1542
+ words := ARRAY[CASE WHEN tag <> '' THEN tag || ' kryptheon test' ELSE 'kryptheon test' END,
1543
+ 'kryptheon' || tag || '@example.com', 'KN' || CASE WHEN tag <> '' THEN tag ELSE '1' END,
1544
+ (step + 1)::text, 'https://example.com/kryptheon', 'kryptheon' || tag, 'Kryptheon' || tag,
1545
+ 'kn-' || CASE WHEN tag <> '' THEN tag ELSE '1' END];
1546
+ FOREACH definition IN ARRAY coalesce(definitions, ARRAY[]::text[]) LOOP
1547
+ FOR one IN SELECT (m)[1] FROM regexp_matches(definition, '''((?:[^'']|'''')*)''', 'g') m LOOP
1548
+ literals := literals || replace(one, '''''', '''');
1549
+ END LOOP;
1550
+ END LOOP;
1551
+ codes := ARRAY['US', 'IN', 'USD', 'INR', 'EUR', 'en', 'en-US', '#00000' || d, '2026-01-0' || (1 + (step % 9))::text,
1552
+ '1234' || d, '56000' || d,
1553
+ 'kryptheon test value long enough for a rule on a minimum length ' || CASE WHEN tag <> '' THEN tag ELSE '0' END];
1554
+ IF name_ ~ 'phone|mobile|whatsapp|tel|msisdn' THEN first_ := phones;
1555
+ ELSIF name_ ~ 'mail' THEN first_ := emails;
1556
+ ELSIF name_ ~ 'url|link|website|site|href' THEN first_ := links;
1557
+ END IF;
1558
+ FOREACH value_ IN ARRAY first_ || words || literals || phones || emails || links || codes LOOP
1559
+ value_ := __KN__.fit_to(value_, kind);
1560
+ IF NOT (value_ = ANY (out_)) THEN out_ := out_ || value_; END IF;
1561
+ END LOOP;
1562
+ RETURN out_;
1563
+ END $$;
1564
+
1565
+ /*
1566
+ * The first values a column's CHECK constraints accept, asked of the
1567
+ * database itself: each rule is run on the value alone, the column given as
1568
+ * the value by a one-row subquery. A rule that also names another column
1569
+ * cannot be run alone and is left out; when no rule can be, nothing is
1570
+ * offered and the seeder goes on as before. At most five - as many as the
1571
+ * seeder ever tries. Mirrors fittingValues in attack.js.
1572
+ */
1573
+ CREATE OR REPLACE FUNCTION __KN__.fitting_values(tab jsonb, col jsonb, distinct_ text)
1574
+ RETURNS text[] LANGUAGE plpgsql AS $$
1575
+ DECLARE
1576
+ name_ text := col->>'name';
1577
+ type_ text := col->>'type';
1578
+ definitions text[] := __KN__.checks_on(tab, col->>'name');
1579
+ candidates text[];
1580
+ runnable text[] := ARRAY[]::text[];
1581
+ all_ text;
1582
+ accepted text[] := ARRAY[]::text[];
1583
+ expression text;
1584
+ value_ text;
1585
+ ok boolean;
1586
+ BEGIN
1587
+ IF coalesce(array_length(definitions, 1), 0) = 0 THEN RETURN accepted; END IF;
1588
+ candidates := __KN__.check_candidates(col, definitions, distinct_);
1589
+ IF coalesce(array_length(candidates, 1), 0) = 0 THEN RETURN accepted; END IF;
1590
+ FOREACH expression IN ARRAY definitions LOOP
1591
+ BEGIN
1592
+ EXECUTE format('SELECT (%s) IS NOT FALSE AS ok FROM (SELECT NULL::%s AS %I) AS kn_row', expression, type_, name_);
1593
+ runnable := runnable || expression;
1594
+ EXCEPTION WHEN OTHERS THEN
1595
+ -- Names another column, or something only the table can supply.
1596
+ NULL;
1597
+ END;
1598
+ END LOOP;
1599
+ IF coalesce(array_length(runnable, 1), 0) = 0 THEN RETURN accepted; END IF;
1600
+ SELECT string_agg('(' || e || ') IS NOT FALSE', ' AND ') INTO all_ FROM unnest(runnable) e;
1601
+ FOREACH value_ IN ARRAY candidates LOOP
1602
+ BEGIN
1603
+ EXECUTE format('SELECT %s AS ok FROM (SELECT $1::%s AS %I) AS kn_row', all_, type_, name_) INTO ok USING value_;
1604
+ IF ok IS TRUE THEN
1605
+ accepted := accepted || value_;
1606
+ EXIT WHEN array_length(accepted, 1) >= 5;
1607
+ END IF;
1608
+ EXCEPTION WHEN OTHERS THEN
1609
+ -- A value its own type, or the rule, refuses outright.
1610
+ NULL;
1611
+ END;
1612
+ END LOOP;
1613
+ RETURN accepted;
1614
+ END $$;
1615
+
1303
1616
  /*
1304
1617
  * Something valid to put in a column, so a row can exist at all.
1305
1618
  *
@@ -1454,6 +1767,31 @@ BEGIN
1454
1767
  RETURN keys;
1455
1768
  END $$;
1456
1769
 
1770
+ /*
1771
+ * The column that says which organization's a row is, on a table with no
1772
+ * person's name on it - or NULL. Its first single-column key to a table that
1773
+ * others point at, where the table itself is not one of those (attack.js
1774
+ * tenantKey). Found on a blind test (HarborLine): integration_secrets was
1775
+ * readable by every signed-in user, and with no person column it was never
1776
+ * tried.
1777
+ */
1778
+ CREATE OR REPLACE FUNCTION __KN__.tenant_key(tab jsonb, tables jsonb, parents jsonb)
1779
+ RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
1780
+ DECLARE
1781
+ key jsonb;
1782
+ BEGIN
1783
+ -- A view is not seeded: it shows whatever its tables hold, the second
1784
+ -- person's own organization included (workspace_health, on the same test).
1785
+ IF coalesce((tab->>'isView')::boolean, false) OR parents ? (tab->>'name') THEN RETURN NULL; END IF;
1786
+ FOR key IN SELECT * FROM jsonb_array_elements(__KN__.foreign_keys(tab) || __KN__.implied_keys(tab, tables)) LOOP
1787
+ IF key->>'refTable' <> tab->>'name' AND parents ? (key->>'refTable')
1788
+ AND jsonb_array_length(key->'columns') = 1 THEN
1789
+ RETURN key->'columns'->>0;
1790
+ END IF;
1791
+ END LOOP;
1792
+ RETURN NULL;
1793
+ END $$;
1794
+
1457
1795
  /* Every table some other table points at, by a key it declared or one it implies. */
1458
1796
  CREATE OR REPLACE FUNCTION __KN__.parent_tables(tables jsonb)
1459
1797
  RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
@@ -1794,8 +2132,12 @@ BEGIN
1794
2132
  CONTINUE WHEN NOT (col->>'not_null')::boolean AND NOT must_fill;
1795
2133
 
1796
2134
  columns := columns || to_jsonb(name);
1797
- -- A CHECK that lists what it will accept beats anything invented here.
2135
+ -- A CHECK that lists what it will accept beats anything invented here;
2136
+ -- after that, a value one it could not read was asked about and accepts.
1798
2137
  allowed := __KN__.allowed_by_check(tab, name);
2138
+ IF coalesce(array_length(allowed, 1), 0) = 0 THEN
2139
+ allowed := __KN__.fitting_values(tab, col, distinct_);
2140
+ END IF;
1799
2141
  IF array_length(allowed, 1) > 0 THEN
1800
2142
  nth := least(greatest(coalesce(attempt, 0), 0), array_length(allowed, 1) - 1);
1801
2143
  values_ := values_ || jsonb_build_array(to_jsonb(allowed[nth + 1]));
@@ -1942,8 +2284,19 @@ BEGIN
1942
2284
  -- into this table can use the same one instead of rediscovering it, and
1943
2285
  -- be sure a refusal is the app defending itself rather than a CHECK it
1944
2286
  -- never satisfied.
2287
+ -- A table with nobody's name on it that got a row for each person keeps
2288
+ -- which row is the first person's, so a write meant for somebody else's
2289
+ -- row is aimed at it (attack.js seed; HarborLine's invoices).
1945
2290
  seeded := seeded || jsonb_build_array(jsonb_build_object(
1946
- 'table', tab->>'name', 'owner', owner, 'attempt', worked));
2291
+ 'table', tab->>'name', 'owner', owner, 'attempt', worked)
2292
+ || CASE WHEN owner IS NULL
2293
+ AND (SELECT count(*) FROM jsonb_object_keys(rows_->(tab->>'name'))) = 2
2294
+ AND __KN__.primary_key_of(tab) IS NOT NULL
2295
+ AND rows_->(tab->>'name')->__KN__.user_a()->>__KN__.primary_key_of(tab) IS NOT NULL
2296
+ THEN jsonb_build_object('rowOfA', jsonb_build_object(
2297
+ 'column', __KN__.primary_key_of(tab),
2298
+ 'value', rows_->(tab->>'name')->__KN__.user_a()->>__KN__.primary_key_of(tab)))
2299
+ ELSE '{}'::jsonb END);
1947
2300
  ELSE
1948
2301
  -- Recorded, never swallowed. The report has to say this table was not
1949
2302
  -- checked rather than let an empty table pass for a safe one.
@@ -2093,9 +2446,13 @@ DECLARE
2093
2446
  key text;
2094
2447
  named jsonb;
2095
2448
  reached boolean;
2449
+ parents jsonb := __KN__.parent_tables(tables);
2450
+ tenant text;
2451
+ as_b jsonb;
2096
2452
  BEGIN
2097
2453
  FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
2098
2454
  owner := __KN__.owner_column(tab);
2455
+ tenant := CASE WHEN owner IS NULL THEN __KN__.tenant_key(tab, tables, parents) END;
2099
2456
  named := coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c),
2100
2457
  '[]'::jsonb);
2101
2458
 
@@ -2170,6 +2527,35 @@ BEGIN
2170
2527
  'why', 'as a signed-in customer: ' || (as_a->>'why')));
2171
2528
  END IF;
2172
2529
  END IF;
2530
+
2531
+ -- A table whose rows are an organization's, not a person's: its one row
2532
+ -- is the first person's organization's, and the second person - signed
2533
+ -- in, in an organization of their own - asks for it. What a logged-out
2534
+ -- stranger can already read is reported as exposed, and not twice.
2535
+ IF tenant IS NOT NULL THEN
2536
+ key := 'crossed:' || (tab->>'name');
2537
+ as_b := __KN__.read_as(source, tab->>'name', 'authenticated', __KN__.user_b(), NULL);
2538
+ IF (as_b->>'ok')::boolean THEN
2539
+ completed := completed || to_jsonb(key);
2540
+ IF (as_b->>'count')::bigint > 0
2541
+ AND NOT ((anon->>'ok')::boolean AND (anon->>'count')::bigint > 0) THEN
2542
+ findings := findings || jsonb_build_array(jsonb_build_object(
2543
+ 'kind', 'crossed',
2544
+ 'table', tab->>'name',
2545
+ 'owner', tenant,
2546
+ 'tenant', true,
2547
+ 'readable', (as_b->>'count')::bigint,
2548
+ 'columns', named,
2549
+ 'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
2550
+ END IF;
2551
+ ELSIF __KN__.refusal_means(as_b->>'why') = 'unreachable' THEN
2552
+ completed := completed || to_jsonb(key);
2553
+ ELSE
2554
+ blocked := blocked || jsonb_build_array(jsonb_build_object(
2555
+ 'table', tab->>'name', 'key', key,
2556
+ 'why', 'as a signed-in user in another organization: ' || (as_b->>'why')));
2557
+ END IF;
2558
+ END IF;
2173
2559
  END LOOP;
2174
2560
 
2175
2561
  RETURN jsonb_build_object('findings', findings, 'completed', completed, 'blocked', blocked,
@@ -2340,10 +2726,11 @@ DECLARE
2340
2726
  -- narrow what is allowed and never open it, so it is never the reason.
2341
2727
  rules_for jsonb;
2342
2728
  named jsonb;
2729
+ first_row jsonb;
2343
2730
  BEGIN
2344
2731
  FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
2345
2732
  -- Never seeded, so there is nothing in it to protect.
2346
- SELECT (s->>'attempt')::integer INTO shape
2733
+ SELECT (s->>'attempt')::integer, s->'rowOfA' INTO shape, first_row
2347
2734
  FROM jsonb_array_elements(coalesce(seeded, '[]'::jsonb)) s
2348
2735
  WHERE s->>'table' = tab->>'name';
2349
2736
  CONTINUE WHEN NOT FOUND;
@@ -2369,8 +2756,14 @@ BEGIN
2369
2756
  -- Rows belonging to the other fake person. Scoped on purpose: a signed-in
2370
2757
  -- customer deleting their OWN rows is not a finding, it is the feature,
2371
2758
  -- 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;
2759
+ -- With nobody's name on the table, the first person's own row where each
2760
+ -- person got one (seed's rowOfA).
2761
+ their_rows := CASE
2762
+ WHEN owner IS NOT NULL
2763
+ THEN ' WHERE ' || __KN__.always_quote(owner) || ' = ' || quote_literal(__KN__.user_a())
2764
+ WHEN jsonb_typeof(first_row) = 'object'
2765
+ THEN ' WHERE ' || __KN__.always_quote(first_row->>'column') || '::text = ' || quote_literal(first_row->>'value')
2766
+ ELSE '' END;
2374
2767
 
2375
2768
  -- Written under a name nobody has used, which is both what makes the row
2376
2769
  -- land at all and what makes it the right test: adding a row of your own
@@ -2641,7 +3034,8 @@ DECLARE
2641
3034
  in_rule_literal text := '''\{([^}'']*,)?"?' || regexp_replace(role_, esc, '\\\1', 'g') ||
2642
3035
  '"?(,[^}'']*)?\}''::("?[A-Za-z0-9_$]+"?\.)?"?' || type_name || '"?\[\]';
2643
3036
  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;
3037
+ -- A takeover is an UPDATE too (tamper.js COMMAND).
3038
+ command text := CASE what WHEN 'add' THEN 'INSERT' WHEN 'change' THEN 'UPDATE' WHEN 'delete' THEN 'DELETE' WHEN 'takeover' THEN 'UPDATE' END;
2645
3039
  policy jsonb;
2646
3040
  body text;
2647
3041
  BEGIN
@@ -2825,6 +3219,18 @@ BEGIN
2825
3219
  jsonb_build_object('what', 'change',
2826
3220
  'statement', 'UPDATE ' || target || ' SET ' || column_ || ' = ' || column_ || not_theirs),
2827
3221
  jsonb_build_object('what', 'delete', 'statement', 'DELETE FROM ' || target || not_theirs));
3222
+ -- On the members table itself: putting their own id on the first
3223
+ -- person's membership row - taking that member's place, and their role
3224
+ -- (tamper.js; HarborLine). Asked of every role, listed or not.
3225
+ IF tab->>'name' = member->'table'->>'name' THEN
3226
+ where_ := ARRAY[__KN__.always_quote(member->>'person') || ' = ' || quote_literal(__KN__.user_a())];
3227
+ FOR name IN SELECT jsonb_object_keys(pointed) ORDER BY 1 LOOP
3228
+ where_ := where_ || (__KN__.always_quote(name) || ' = ' || quote_literal(pointed->>name));
3229
+ END LOOP;
3230
+ moves := moves || jsonb_build_array(jsonb_build_object('what', 'takeover', 'anyRole', true,
3231
+ 'statement', 'UPDATE ' || target || ' SET ' || __KN__.always_quote(member->>'person') || ' = '
3232
+ || quote_literal(__KN__.user_b()) || ' WHERE ' || array_to_string(where_, ' AND ')));
3233
+ END IF;
2828
3234
 
2829
3235
  -- Adding a row to the team, said only of a role whose own name says it
2830
3236
  -- may only look, and written under the other person's name: a rule that
@@ -2868,7 +3274,8 @@ BEGIN
2868
3274
  CONTINUE WHEN coalesce((move->>'lookOnly')::boolean, false) AND NOT __KN__.look_only(role_);
2869
3275
  -- A write the rules list this role for is not asked: whatever it did
2870
3276
  -- 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
3277
+ IF NOT coalesce((move->>'anyRole')::boolean, false)
3278
+ AND __KN__.allowed_by_rules(rules, tab->>'name', move->>'what', role_, member->>'roleType') THEN
2872
3279
  listed := true;
2873
3280
  CONTINUE;
2874
3281
  END IF;
@@ -2903,7 +3310,10 @@ BEGIN
2903
3310
  'changed', changed,
2904
3311
  'below', below,
2905
3312
  'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c), '[]'::jsonb),
2906
- 'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
3313
+ 'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false))
3314
+ || CASE WHEN can ? 'takeover'
3315
+ THEN jsonb_build_object('takeover', true, 'personColumn', member->>'person')
3316
+ ELSE '{}'::jsonb END);
2907
3317
  EXIT;
2908
3318
  END IF;
2909
3319
  IF NOT listed THEN below := below || to_jsonb(role_); END IF;
@@ -3499,12 +3909,32 @@ BEGIN
3499
3909
  FROM jsonb_array_elements(coalesce(seen->'looped', '[]'::jsonb)) l
3500
3910
  GROUP BY l->>'relation') g), '[]'::jsonb);
3501
3911
 
3912
+ -- Functions anybody can call that run past the rules, and public buckets
3913
+ -- worth a look - read from the app's own shape, never run or opened
3914
+ -- (scan.js privilegedOf and bucketsOf). Found on a blind test
3915
+ -- (HarborLine): neither was ever in the nightly run, so the dashboard
3916
+ -- showed less than the command line did.
3917
+ findings := findings || coalesce((
3918
+ SELECT jsonb_agg(jsonb_build_object(
3919
+ 'kind', 'privileged', 'fn', f->>'name', 'table', f->>'name', 'args', f->>'args',
3920
+ 'writes', (f->>'writes')::boolean, 'hasFixedSearchPath', (f->>'hasFixedSearchPath')::boolean,
3921
+ 'nullSlips', coalesce((f->>'nullSlips')::boolean, false), 'columns', '[]'::jsonb))
3922
+ FROM jsonb_array_elements(coalesce(plan->'anonFunctions', '[]'::jsonb)) f
3923
+ WHERE (f->>'callable')::boolean AND NOT (f->>'aboutCaller')::boolean AND NOT (f->>'guarded')::boolean),
3924
+ '[]'::jsonb)
3925
+ || __KN__.bucket_findings(plan->'buckets');
3926
+
3502
3927
  -- 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.
3928
+ -- fixed from one whose attack simply did not run this time. Every function
3929
+ -- and bucket read counts as looked at, so a revoked grant comes back fixed.
3504
3930
  attempted := coalesce(seen->'completed', '[]'::jsonb)
3505
3931
  || coalesce(wrote->'completed', '[]'::jsonb)
3506
3932
  || coalesce(ranked->'completed', '[]'::jsonb)
3507
- || coalesce(stranded->'completed', '[]'::jsonb);
3933
+ || coalesce(stranded->'completed', '[]'::jsonb)
3934
+ || coalesce((SELECT jsonb_agg('privileged:' || (f->>'name'))
3935
+ FROM jsonb_array_elements(coalesce(plan->'anonFunctions', '[]'::jsonb)) f), '[]'::jsonb)
3936
+ || coalesce((SELECT jsonb_agg('bucket:' || (b->>'name'))
3937
+ FROM jsonb_array_elements(coalesce(plan->'buckets', '[]'::jsonb)) b), '[]'::jsonb);
3508
3938
 
3509
3939
  -- And everything that was not. Said out loud, never left out: a table
3510
3940
  -- nothing could be put into reads exactly like a table nothing got out of.