kryptheon-night 0.1.25 → 0.1.27
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 +11 -0
- package/attack.js +63 -2
- package/bin/kryptheon-night.js +3 -1
- package/engine.sql +571 -3
- package/finding.js +163 -3
- package/intro.js +5 -1
- package/package.json +5 -3
- package/recheck.js +138 -13
- package/scan.js +75 -6
- package/schema.js +59 -0
- package/sqlengine.js +12 -1
- package/tamper.js +334 -1
package/engine.sql
CHANGED
|
@@ -335,6 +335,38 @@ RETURNS jsonb LANGUAGE sql STABLE AS $$
|
|
|
335
335
|
) rows;
|
|
336
336
|
$$;
|
|
337
337
|
|
|
338
|
+
/*
|
|
339
|
+
* Supabase Storage's buckets: whether each is public, and the rules on
|
|
340
|
+
* storage.objects that say who may read it. The settings and the rules only,
|
|
341
|
+
* never a file. Run through EXECUTE because a database without Supabase
|
|
342
|
+
* Storage has no storage.buckets to name, and that failure is caught below
|
|
343
|
+
* and read as no buckets. Mirrors readBuckets in schema.js.
|
|
344
|
+
*/
|
|
345
|
+
CREATE OR REPLACE FUNCTION __KN__.read_buckets()
|
|
346
|
+
RETURNS jsonb LANGUAGE plpgsql STABLE AS $$
|
|
347
|
+
DECLARE
|
|
348
|
+
found jsonb;
|
|
349
|
+
BEGIN
|
|
350
|
+
EXECUTE $q$
|
|
351
|
+
SELECT coalesce(jsonb_agg(jsonb_build_object(
|
|
352
|
+
'id', b.id::text,
|
|
353
|
+
'name', b.name::text,
|
|
354
|
+
'public', coalesce(b.public, false),
|
|
355
|
+
'readRules', (SELECT coalesce(jsonb_agg(jsonb_build_object(
|
|
356
|
+
'name', p.policyname::text,
|
|
357
|
+
'roles', to_jsonb(p.roles::text[]),
|
|
358
|
+
'qual', p.qual) ORDER BY p.policyname), '[]'::jsonb)
|
|
359
|
+
FROM pg_policies p
|
|
360
|
+
WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
|
|
361
|
+
AND p.cmd IN ('SELECT', 'ALL')
|
|
362
|
+
AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0)
|
|
363
|
+
) ORDER BY b.id), '[]'::jsonb)
|
|
364
|
+
FROM storage.buckets b $q$ INTO found;
|
|
365
|
+
RETURN found;
|
|
366
|
+
EXCEPTION WHEN OTHERS THEN
|
|
367
|
+
RETURN '[]'::jsonb;
|
|
368
|
+
END $$;
|
|
369
|
+
|
|
338
370
|
/* Everything needed to rebuild a schema, in one shape. */
|
|
339
371
|
CREATE OR REPLACE FUNCTION __KN__.read_schema(source text)
|
|
340
372
|
RETURNS jsonb LANGUAGE plpgsql STABLE AS $$
|
|
@@ -362,6 +394,7 @@ BEGIN
|
|
|
362
394
|
'views', __KN__.read_views(source),
|
|
363
395
|
'viewGrants', __KN__.read_view_grants(source),
|
|
364
396
|
'functions', __KN__.read_functions(source),
|
|
397
|
+
'buckets', __KN__.read_buckets(),
|
|
365
398
|
-- A foreign key pointing at something whose shape could not be read is
|
|
366
399
|
-- not something to guess at. The caller stops rather than attacking a
|
|
367
400
|
-- copy that is missing a piece.
|
|
@@ -642,6 +675,19 @@ BEGIN
|
|
|
642
675
|
parts := parts || CASE WHEN bare = src THEN '''' || target || ''', ' || part ELSE part END;
|
|
643
676
|
END LOOP;
|
|
644
677
|
def := replace(def, m[1] || m[2], m[1] || array_to_string(parts, ', '));
|
|
678
|
+
ELSE
|
|
679
|
+
-- None pinned, so a bare name in the body is looked up on the caller's
|
|
680
|
+
-- path when it runs, and that path has never heard of the copy. Found on
|
|
681
|
+
-- a blind test (OrbitDesk): the copy's has_role read the original's
|
|
682
|
+
-- members with the original's enum, and every rule calling it failed with
|
|
683
|
+
-- "operator does not exist". Pinned the way the copy was built: the copy
|
|
684
|
+
-- first, then where the original's bare names would have gone. A BEGIN
|
|
685
|
+
-- ATOMIC body has no AS line and needs none.
|
|
686
|
+
SELECT string_agg('''' || s || '''', ', ' ORDER BY first) INTO head
|
|
687
|
+
FROM (SELECT s, min(at) AS first
|
|
688
|
+
FROM unnest(ARRAY[target, src, 'public', 'extensions']) WITH ORDINALITY AS u(s, at)
|
|
689
|
+
GROUP BY s) p;
|
|
690
|
+
def := regexp_replace(def, E'\nAS ', E'\n SET search_path TO ' || head || E'\nAS ');
|
|
645
691
|
END IF;
|
|
646
692
|
RETURN __KN__.rewrite_own_table_refs(def, plan, target, copied);
|
|
647
693
|
END $$;
|
|
@@ -1579,6 +1625,54 @@ END $$;
|
|
|
1579
1625
|
* and `implied`, the keys the app never declared, so a `workspace_id` with no
|
|
1580
1626
|
* foreign key still names a workspace that is there.
|
|
1581
1627
|
*/
|
|
1628
|
+
/*
|
|
1629
|
+
* Sets of columns where exactly one may be filled in. Mirrors exactlyOne in
|
|
1630
|
+
* attack.js: (a IS NOT NULL)::int + (b IS NOT NULL)::int = 1, and
|
|
1631
|
+
* num_nonnulls(a, b) = 1, read only when the constraint says nothing else.
|
|
1632
|
+
* Found on a blind test (HelixOps), where documents pointed at a work order
|
|
1633
|
+
* or an inspection, the seeder pointed it at both, and it was never tested.
|
|
1634
|
+
*/
|
|
1635
|
+
CREATE OR REPLACE FUNCTION __KN__.exactly_one(tab jsonb)
|
|
1636
|
+
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
|
|
1637
|
+
DECLARE
|
|
1638
|
+
name_ constant text := '("(?:[^"]|"")+"|[A-Za-z_][A-Za-z0-9_$]*)';
|
|
1639
|
+
summed text := '\(\(' || name_ || ' IS NOT NULL\)\)::integer';
|
|
1640
|
+
groups jsonb := '[]'::jsonb;
|
|
1641
|
+
con jsonb;
|
|
1642
|
+
definition text;
|
|
1643
|
+
rest text;
|
|
1644
|
+
counted text[];
|
|
1645
|
+
columns jsonb;
|
|
1646
|
+
found text;
|
|
1647
|
+
BEGIN
|
|
1648
|
+
FOR con IN SELECT * FROM jsonb_array_elements(coalesce(tab->'constraints', '[]'::jsonb)) LOOP
|
|
1649
|
+
CONTINUE WHEN con->>'kind' <> 'c';
|
|
1650
|
+
definition := coalesce(con->>'definition', '');
|
|
1651
|
+
columns := '[]'::jsonb;
|
|
1652
|
+
FOR found IN SELECT (m)[1] FROM regexp_matches(definition, summed, 'gi') m LOOP
|
|
1653
|
+
columns := columns || to_jsonb(found);
|
|
1654
|
+
END LOOP;
|
|
1655
|
+
rest := replace(regexp_replace(definition, summed, '', 'gi'), '+', '');
|
|
1656
|
+
IF jsonb_array_length(columns) = 0 THEN
|
|
1657
|
+
counted := regexp_match(definition, 'num_nonnulls\(([^()]*)\)', 'i');
|
|
1658
|
+
IF counted IS NOT NULL THEN
|
|
1659
|
+
FOR found IN SELECT (m)[1] FROM regexp_matches(counted[1], name_, 'g') m LOOP
|
|
1660
|
+
columns := columns || to_jsonb(found);
|
|
1661
|
+
END LOOP;
|
|
1662
|
+
rest := regexp_replace(rest, 'num_nonnulls\([^()]*\)', '', 'i');
|
|
1663
|
+
END IF;
|
|
1664
|
+
END IF;
|
|
1665
|
+
-- Nothing may be left but the CHECK itself, brackets and "= 1".
|
|
1666
|
+
CONTINUE WHEN jsonb_array_length(columns) < 2
|
|
1667
|
+
OR upper(regexp_replace(rest, '[()\s]', '', 'g')) <> 'CHECK=1';
|
|
1668
|
+
groups := groups || jsonb_build_array((
|
|
1669
|
+
SELECT jsonb_agg(CASE WHEN left(c, 1) = '"' THEN replace(substr(c, 2, length(c) - 2), '""', '"') ELSE c END
|
|
1670
|
+
ORDER BY at)
|
|
1671
|
+
FROM jsonb_array_elements_text(columns) WITH ORDINALITY AS e(c, at)));
|
|
1672
|
+
END LOOP;
|
|
1673
|
+
RETURN groups;
|
|
1674
|
+
END $$;
|
|
1675
|
+
|
|
1582
1676
|
CREATE OR REPLACE FUNCTION __KN__.row_for(
|
|
1583
1677
|
source text, tab jsonb, person text, distinct_ text, overrides jsonb, attempt integer,
|
|
1584
1678
|
seeding jsonb DEFAULT NULL)
|
|
@@ -1590,6 +1684,10 @@ DECLARE
|
|
|
1590
1684
|
values_ jsonb := '[]'::jsonb;
|
|
1591
1685
|
borrowed jsonb := '{}'::jsonb;
|
|
1592
1686
|
part_of_key jsonb := '[]'::jsonb;
|
|
1687
|
+
only_one jsonb := '{}'::jsonb;
|
|
1688
|
+
group_ jsonb;
|
|
1689
|
+
kept text;
|
|
1690
|
+
must_fill boolean;
|
|
1593
1691
|
key jsonb;
|
|
1594
1692
|
row_ jsonb;
|
|
1595
1693
|
col jsonb;
|
|
@@ -1618,6 +1716,22 @@ BEGIN
|
|
|
1618
1716
|
part_of_key := part_of_key || (key->'columns');
|
|
1619
1717
|
END LOOP;
|
|
1620
1718
|
|
|
1719
|
+
-- Where exactly one of a set may be filled in: the one already chosen, or
|
|
1720
|
+
-- else the first that can be - a key needs a parent row to point at. The
|
|
1721
|
+
-- rest are written as nothing, whatever their default.
|
|
1722
|
+
FOR group_ IN SELECT * FROM jsonb_array_elements(__KN__.exactly_one(tab)) LOOP
|
|
1723
|
+
SELECT c INTO kept FROM jsonb_array_elements_text(group_) WITH ORDINALITY AS e(c, pos)
|
|
1724
|
+
WHERE forced ? c ORDER BY pos LIMIT 1;
|
|
1725
|
+
IF kept IS NULL THEN
|
|
1726
|
+
SELECT c INTO kept FROM jsonb_array_elements_text(group_) WITH ORDINALITY AS e(c, pos)
|
|
1727
|
+
WHERE NOT (part_of_key ? c) OR borrowed ? c ORDER BY pos LIMIT 1;
|
|
1728
|
+
END IF;
|
|
1729
|
+
FOR name IN SELECT jsonb_array_elements_text(group_) LOOP
|
|
1730
|
+
only_one := jsonb_set(only_one, ARRAY[name], to_jsonb(name = kept));
|
|
1731
|
+
END LOOP;
|
|
1732
|
+
kept := NULL;
|
|
1733
|
+
END LOOP;
|
|
1734
|
+
|
|
1621
1735
|
FOR col IN SELECT * FROM jsonb_array_elements(tab->'columns') LOOP
|
|
1622
1736
|
name := col->>'name';
|
|
1623
1737
|
|
|
@@ -1627,6 +1741,13 @@ BEGIN
|
|
|
1627
1741
|
CONTINUE;
|
|
1628
1742
|
END IF;
|
|
1629
1743
|
|
|
1744
|
+
IF only_one->name = 'false'::jsonb THEN
|
|
1745
|
+
columns := columns || to_jsonb(name);
|
|
1746
|
+
values_ := values_ || jsonb_build_array('null'::jsonb);
|
|
1747
|
+
CONTINUE;
|
|
1748
|
+
END IF;
|
|
1749
|
+
must_fill := coalesce(only_one->name = 'true'::jsonb, false);
|
|
1750
|
+
|
|
1630
1751
|
IF name = owner THEN
|
|
1631
1752
|
columns := columns || to_jsonb(name);
|
|
1632
1753
|
values_ := values_ || jsonb_build_array(to_jsonb(person));
|
|
@@ -1666,9 +1787,11 @@ BEGIN
|
|
|
1666
1787
|
values_ := values_ || jsonb_build_array(borrowed->name);
|
|
1667
1788
|
CONTINUE;
|
|
1668
1789
|
END IF;
|
|
1669
|
-
-- Anything with a default can supply its own value.
|
|
1790
|
+
-- Anything with a default can supply its own value. A column that may be
|
|
1791
|
+
-- empty is left empty - unless it is the one of a set that has to be
|
|
1792
|
+
-- filled in.
|
|
1670
1793
|
CONTINUE WHEN col->>'default_expr' IS NOT NULL;
|
|
1671
|
-
CONTINUE WHEN NOT (col->>'not_null')::boolean;
|
|
1794
|
+
CONTINUE WHEN NOT (col->>'not_null')::boolean AND NOT must_fill;
|
|
1672
1795
|
|
|
1673
1796
|
columns := columns || to_jsonb(name);
|
|
1674
1797
|
-- A CHECK that lists what it will accept beats anything invented here.
|
|
@@ -2316,6 +2439,447 @@ BEGIN
|
|
|
2316
2439
|
RETURN jsonb_build_object('findings', findings, 'completed', completed, 'blocked', blocked);
|
|
2317
2440
|
END $$;
|
|
2318
2441
|
|
|
2442
|
+
-- --------------------------------------------------------------------------
|
|
2443
|
+
-- What the least trusted member of a team can change.
|
|
2444
|
+
--
|
|
2445
|
+
-- Mirrors teammate in tamper.js. One fake person joins the other's team with
|
|
2446
|
+
-- the lowest role there is, for one attempt at a time, and every write is
|
|
2447
|
+
-- tried before joining and after, so only what joining added is counted.
|
|
2448
|
+
-- Reported as something to check: whether a viewer may edit is the owner's
|
|
2449
|
+
-- decision. Found on two blind tests, where five holes of this shape - "any
|
|
2450
|
+
-- member may change it", or an UPDATE rule that checks the role beside one
|
|
2451
|
+
-- that does not - went unreported.
|
|
2452
|
+
-- --------------------------------------------------------------------------
|
|
2453
|
+
|
|
2454
|
+
/* The role a viewer has, out of a team's list of roles, or null. */
|
|
2455
|
+
CREATE OR REPLACE FUNCTION __KN__.lowest_role(labels jsonb)
|
|
2456
|
+
RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
|
|
2457
|
+
DECLARE
|
|
2458
|
+
pattern text;
|
|
2459
|
+
found text;
|
|
2460
|
+
BEGIN
|
|
2461
|
+
-- Labels that say "may only look", tried in this order before falling back
|
|
2462
|
+
-- to the last label that is not plainly in charge.
|
|
2463
|
+
FOREACH pattern IN ARRAY ARRAY['view', 'read', 'guest', 'observ', 'audit'] LOOP
|
|
2464
|
+
SELECT l INTO found FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, at)
|
|
2465
|
+
WHERE l ~* pattern ORDER BY at LIMIT 1;
|
|
2466
|
+
IF found IS NOT NULL THEN RETURN found; END IF;
|
|
2467
|
+
END LOOP;
|
|
2468
|
+
SELECT l INTO found FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, at)
|
|
2469
|
+
WHERE l !~* 'owner|admin|super|root' ORDER BY at DESC LIMIT 1;
|
|
2470
|
+
RETURN found;
|
|
2471
|
+
END $$;
|
|
2472
|
+
|
|
2473
|
+
/*
|
|
2474
|
+
* The column holding a member's role, and its lowest value: named for a role,
|
|
2475
|
+
* with a fixed list of values.
|
|
2476
|
+
*/
|
|
2477
|
+
CREATE OR REPLACE FUNCTION __KN__.role_column_of(tab jsonb)
|
|
2478
|
+
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
|
|
2479
|
+
DECLARE
|
|
2480
|
+
col jsonb;
|
|
2481
|
+
labels jsonb;
|
|
2482
|
+
lowest text;
|
|
2483
|
+
BEGIN
|
|
2484
|
+
FOR col IN SELECT * FROM jsonb_array_elements(coalesce(tab->'columns', '[]'::jsonb)) LOOP
|
|
2485
|
+
CONTINUE WHEN (col->>'name') !~* 'role';
|
|
2486
|
+
IF jsonb_typeof(col->'enum_labels') = 'array' AND jsonb_array_length(col->'enum_labels') > 0 THEN
|
|
2487
|
+
labels := col->'enum_labels';
|
|
2488
|
+
ELSE
|
|
2489
|
+
labels := to_jsonb(__KN__.allowed_by_check(tab, col->>'name'));
|
|
2490
|
+
END IF;
|
|
2491
|
+
CONTINUE WHEN labels IS NULL OR jsonb_array_length(labels) < 2;
|
|
2492
|
+
lowest := __KN__.lowest_role(labels);
|
|
2493
|
+
IF lowest IS NOT NULL THEN
|
|
2494
|
+
-- Every role not plainly in charge, the lowest first and then upwards:
|
|
2495
|
+
-- the order reads are tried in.
|
|
2496
|
+
RETURN jsonb_build_object('column', col->>'name', 'lowest', lowest, 'ladder',
|
|
2497
|
+
jsonb_build_array(lowest) || coalesce((
|
|
2498
|
+
SELECT jsonb_agg(l ORDER BY pos DESC)
|
|
2499
|
+
FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, pos)
|
|
2500
|
+
WHERE l <> lowest AND l !~* 'owner|admin|super|root'), '[]'::jsonb));
|
|
2501
|
+
END IF;
|
|
2502
|
+
END LOOP;
|
|
2503
|
+
RETURN NULL;
|
|
2504
|
+
END $$;
|
|
2505
|
+
|
|
2506
|
+
/*
|
|
2507
|
+
* The columns of a table that look like they hold a secret: a token, a secret
|
|
2508
|
+
* or a key, a password, or the payload a webhook carried. Mirrors
|
|
2509
|
+
* secretColumns in tamper.js. Found on a blind test (HelixOps).
|
|
2510
|
+
*/
|
|
2511
|
+
CREATE OR REPLACE FUNCTION __KN__.secret_columns(tab jsonb)
|
|
2512
|
+
RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
|
|
2513
|
+
SELECT coalesce(jsonb_agg(c->>'name' ORDER BY pos), '[]'::jsonb)
|
|
2514
|
+
FROM jsonb_array_elements(coalesce(tab->'columns', '[]'::jsonb)) WITH ORDINALITY AS e(c, pos)
|
|
2515
|
+
WHERE (c->>'name') ~* '(^|_)(token|secret|password|passwd|pwd|payload|credentials?|(api|private|access|client|signing|service|encryption|secret|license)_?key|(request|response)_body)(_|$)';
|
|
2516
|
+
$$;
|
|
2517
|
+
|
|
2518
|
+
/* Whether a role's own name says it may only look. */
|
|
2519
|
+
CREATE OR REPLACE FUNCTION __KN__.look_only(role_ text)
|
|
2520
|
+
RETURNS boolean LANGUAGE sql IMMUTABLE AS $$
|
|
2521
|
+
SELECT role_ ~* 'view' OR role_ ~* 'read' OR role_ ~* 'guest' OR role_ ~* 'observ' OR role_ ~* 'audit';
|
|
2522
|
+
$$;
|
|
2523
|
+
|
|
2524
|
+
/* Whether a column is a key on its own: one row per person, at most. */
|
|
2525
|
+
CREATE OR REPLACE FUNCTION __KN__.alone_unique(tab jsonb, column_name text)
|
|
2526
|
+
RETURNS boolean LANGUAGE sql IMMUTABLE AS $$
|
|
2527
|
+
SELECT EXISTS (
|
|
2528
|
+
SELECT 1
|
|
2529
|
+
FROM jsonb_array_elements(coalesce(tab->'constraints', '[]'::jsonb)) c,
|
|
2530
|
+
LATERAL regexp_match(c->>'definition', '(?:PRIMARY KEY|UNIQUE) \(([^)]+)\)', 'i') m
|
|
2531
|
+
WHERE c->>'kind' IN ('p', 'u')
|
|
2532
|
+
AND array_length(string_to_array(m[1], ','), 1) = 1
|
|
2533
|
+
AND replace(trim(m[1]), '"', '') = column_name);
|
|
2534
|
+
$$;
|
|
2535
|
+
|
|
2536
|
+
/*
|
|
2537
|
+
* The tables saying who is in which team, and as what: a person, a role, and
|
|
2538
|
+
* a key to a table of the app's own that is not the person. A table where the
|
|
2539
|
+
* person can appear only once is not one: nobody joins a second team there.
|
|
2540
|
+
*/
|
|
2541
|
+
CREATE OR REPLACE FUNCTION __KN__.memberships(tables jsonb)
|
|
2542
|
+
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
|
|
2543
|
+
DECLARE
|
|
2544
|
+
found jsonb := '[]'::jsonb;
|
|
2545
|
+
tab jsonb;
|
|
2546
|
+
person text;
|
|
2547
|
+
role_ jsonb;
|
|
2548
|
+
keys jsonb;
|
|
2549
|
+
BEGIN
|
|
2550
|
+
FOR tab IN SELECT * FROM jsonb_array_elements(coalesce(tables, '[]'::jsonb)) LOOP
|
|
2551
|
+
person := __KN__.owner_column(tab);
|
|
2552
|
+
-- Whoever made the row is not who is in the team: an invitation's
|
|
2553
|
+
-- created_by makes nobody a member of anything.
|
|
2554
|
+
CONTINUE WHEN person IS NULL OR person ~* '^(created_by|author_id|owner|owner_id)$';
|
|
2555
|
+
role_ := __KN__.role_column_of(tab);
|
|
2556
|
+
CONTINUE WHEN role_ IS NULL;
|
|
2557
|
+
SELECT coalesce(jsonb_agg(k ORDER BY at), '[]'::jsonb) INTO keys
|
|
2558
|
+
FROM jsonb_array_elements(__KN__.foreign_keys(tab) || __KN__.implied_keys(tab, tables))
|
|
2559
|
+
WITH ORDINALITY AS e(k, at)
|
|
2560
|
+
WHERE k->>'refTable' <> tab->>'name'
|
|
2561
|
+
AND EXISTS (SELECT 1 FROM jsonb_array_elements(tables) t WHERE t->>'name' = k->>'refTable')
|
|
2562
|
+
AND NOT (k->'columns' ? person);
|
|
2563
|
+
CONTINUE WHEN jsonb_array_length(keys) = 0 OR __KN__.alone_unique(tab, person);
|
|
2564
|
+
found := found || jsonb_build_array(jsonb_build_object(
|
|
2565
|
+
'table', tab, 'person', person, 'role', role_->>'column', 'lowest', role_->>'lowest',
|
|
2566
|
+
'ladder', role_->'ladder', 'keys', keys));
|
|
2567
|
+
END LOOP;
|
|
2568
|
+
-- A table named for members first: it is the one a team rule asks.
|
|
2569
|
+
SELECT coalesce(jsonb_agg(m ORDER BY (m->'table'->>'name') !~* 'member', at), '[]'::jsonb) INTO found
|
|
2570
|
+
FROM jsonb_array_elements(found) WITH ORDINALITY AS e(m, at);
|
|
2571
|
+
RETURN found;
|
|
2572
|
+
END $$;
|
|
2573
|
+
|
|
2574
|
+
/*
|
|
2575
|
+
* try_write, with something the copy's owner does first inside the same
|
|
2576
|
+
* subtransaction - putting a person into a team - so it is undone with the
|
|
2577
|
+
* write. A function of its own rather than a fourth argument: an installed
|
|
2578
|
+
* engine upgraded in place would keep the old three-argument one beside it,
|
|
2579
|
+
* and every call would become ambiguous.
|
|
2580
|
+
*/
|
|
2581
|
+
CREATE OR REPLACE FUNCTION __KN__.try_write_joined(role_ text, identity text, statement text, join_ text)
|
|
2582
|
+
RETURNS jsonb LANGUAGE plpgsql AS $$
|
|
2583
|
+
DECLARE
|
|
2584
|
+
claims text := CASE WHEN identity IS NULL
|
|
2585
|
+
THEN json_build_object('role', role_)::text
|
|
2586
|
+
ELSE json_build_object('sub', identity, 'role', role_)::text END;
|
|
2587
|
+
moved bigint := 0;
|
|
2588
|
+
worked boolean := false;
|
|
2589
|
+
why text := NULL;
|
|
2590
|
+
BEGIN
|
|
2591
|
+
BEGIN
|
|
2592
|
+
EXECUTE join_;
|
|
2593
|
+
EXECUTE 'SET LOCAL statement_timeout = ' || quote_literal('15s');
|
|
2594
|
+
EXECUTE 'SET LOCAL ROLE ' || quote_ident(role_);
|
|
2595
|
+
PERFORM set_config('request.jwt.claims', claims, true);
|
|
2596
|
+
|
|
2597
|
+
EXECUTE statement;
|
|
2598
|
+
GET DIAGNOSTICS moved = ROW_COUNT;
|
|
2599
|
+
worked := true;
|
|
2600
|
+
|
|
2601
|
+
RAISE EXCEPTION 'kryptheon: undoing the write' USING ERRCODE = 'KN001';
|
|
2602
|
+
EXCEPTION
|
|
2603
|
+
WHEN SQLSTATE 'KN001' THEN
|
|
2604
|
+
NULL;
|
|
2605
|
+
WHEN OTHERS THEN
|
|
2606
|
+
worked := false;
|
|
2607
|
+
moved := 0;
|
|
2608
|
+
why := SQLERRM;
|
|
2609
|
+
END;
|
|
2610
|
+
|
|
2611
|
+
RESET ROLE;
|
|
2612
|
+
PERFORM set_config('request.jwt.claims', '', true);
|
|
2613
|
+
PERFORM set_config('statement_timeout', '0', true);
|
|
2614
|
+
RETURN jsonb_build_object('ok', worked, 'count', moved, 'why', why);
|
|
2615
|
+
END $$;
|
|
2616
|
+
|
|
2617
|
+
/* A write's rows, or null when it decided nothing. */
|
|
2618
|
+
CREATE OR REPLACE FUNCTION __KN__.rows_moved(result jsonb)
|
|
2619
|
+
RETURNS bigint LANGUAGE sql IMMUTABLE AS $$
|
|
2620
|
+
SELECT CASE __KN__.what_happened(result)
|
|
2621
|
+
WHEN 'untested' THEN NULL
|
|
2622
|
+
WHEN 'got through' THEN (result->>'count')::bigint
|
|
2623
|
+
ELSE 0 END;
|
|
2624
|
+
$$;
|
|
2625
|
+
|
|
2626
|
+
CREATE OR REPLACE FUNCTION __KN__.teammate(source text, tables jsonb, seeded jsonb)
|
|
2627
|
+
RETURNS jsonb LANGUAGE plpgsql AS $$
|
|
2628
|
+
DECLARE
|
|
2629
|
+
findings jsonb := '[]'::jsonb;
|
|
2630
|
+
completed jsonb := '[]'::jsonb;
|
|
2631
|
+
blocked jsonb := '[]'::jsonb;
|
|
2632
|
+
done jsonb := '[]'::jsonb;
|
|
2633
|
+
member jsonb;
|
|
2634
|
+
tab jsonb;
|
|
2635
|
+
shape integer;
|
|
2636
|
+
at text;
|
|
2637
|
+
theirs jsonb;
|
|
2638
|
+
team jsonb;
|
|
2639
|
+
overrides jsonb;
|
|
2640
|
+
joins jsonb;
|
|
2641
|
+
key jsonb;
|
|
2642
|
+
name text;
|
|
2643
|
+
parent text;
|
|
2644
|
+
parent_key jsonb;
|
|
2645
|
+
to_team jsonb;
|
|
2646
|
+
pointed jsonb;
|
|
2647
|
+
row_ jsonb;
|
|
2648
|
+
join_ text;
|
|
2649
|
+
refused text;
|
|
2650
|
+
owner text;
|
|
2651
|
+
where_ text[];
|
|
2652
|
+
not_theirs text;
|
|
2653
|
+
target text;
|
|
2654
|
+
column_ text;
|
|
2655
|
+
finding_key text;
|
|
2656
|
+
read_key text;
|
|
2657
|
+
moves jsonb;
|
|
2658
|
+
move jsonb;
|
|
2659
|
+
was bigint;
|
|
2660
|
+
now_ bigint;
|
|
2661
|
+
before_ jsonb;
|
|
2662
|
+
after_ jsonb;
|
|
2663
|
+
can jsonb;
|
|
2664
|
+
changed jsonb;
|
|
2665
|
+
stuck text;
|
|
2666
|
+
secrets jsonb;
|
|
2667
|
+
look text;
|
|
2668
|
+
role_ text;
|
|
2669
|
+
tried jsonb;
|
|
2670
|
+
i integer;
|
|
2671
|
+
BEGIN
|
|
2672
|
+
FOR member IN SELECT * FROM jsonb_array_elements(__KN__.memberships(tables)) LOOP
|
|
2673
|
+
SELECT (s->>'attempt')::integer INTO shape
|
|
2674
|
+
FROM jsonb_array_elements(coalesce(seeded, '[]'::jsonb)) s
|
|
2675
|
+
WHERE s->>'table' = member->'table'->>'name';
|
|
2676
|
+
CONTINUE WHEN NOT FOUND;
|
|
2677
|
+
shape := coalesce(shape, 0);
|
|
2678
|
+
at := __KN__.always_quote(source) || '.' || __KN__.always_quote(member->'table'->>'name');
|
|
2679
|
+
BEGIN
|
|
2680
|
+
EXECUTE 'SELECT to_jsonb(t) FROM ' || at || ' t WHERE ' || __KN__.always_quote(member->>'person') ||
|
|
2681
|
+
' = ' || quote_literal(__KN__.user_a()) || ' LIMIT 1' INTO theirs;
|
|
2682
|
+
EXCEPTION WHEN OTHERS THEN
|
|
2683
|
+
theirs := NULL;
|
|
2684
|
+
END;
|
|
2685
|
+
CONTINUE WHEN theirs IS NULL;
|
|
2686
|
+
|
|
2687
|
+
-- Their team, as the first person's own membership names it.
|
|
2688
|
+
team := '{}'::jsonb;
|
|
2689
|
+
FOR key IN SELECT * FROM jsonb_array_elements(member->'keys') LOOP
|
|
2690
|
+
FOR name IN SELECT jsonb_array_elements_text(key->'columns') LOOP
|
|
2691
|
+
IF name <> member->>'person' THEN
|
|
2692
|
+
team := jsonb_set(team, ARRAY[name], coalesce(theirs->name, 'null'::jsonb));
|
|
2693
|
+
END IF;
|
|
2694
|
+
END LOOP;
|
|
2695
|
+
END LOOP;
|
|
2696
|
+
parent_key := member->'keys'->0;
|
|
2697
|
+
parent := parent_key->>'refTable';
|
|
2698
|
+
|
|
2699
|
+
-- Putting the other person in that team, as each role in turn. Built up
|
|
2700
|
+
-- front, before anybody changes role.
|
|
2701
|
+
joins := '{}'::jsonb;
|
|
2702
|
+
refused := NULL;
|
|
2703
|
+
FOR role_ IN SELECT jsonb_array_elements_text(member->'ladder') LOOP
|
|
2704
|
+
BEGIN
|
|
2705
|
+
overrides := jsonb_set(team, ARRAY[member->>'role'], to_jsonb(role_));
|
|
2706
|
+
row_ := __KN__.row_for(source, member->'table', __KN__.user_b(), '9', overrides, shape);
|
|
2707
|
+
joins := jsonb_set(joins, ARRAY[role_],
|
|
2708
|
+
to_jsonb(__KN__.insert_statement(source, member->'table'->>'name', row_)));
|
|
2709
|
+
EXCEPTION WHEN OTHERS THEN
|
|
2710
|
+
IF role_ = member->>'lowest' THEN refused := SQLERRM; END IF;
|
|
2711
|
+
END;
|
|
2712
|
+
END LOOP;
|
|
2713
|
+
join_ := joins->>(member->>'lowest');
|
|
2714
|
+
|
|
2715
|
+
-- Whether they can be put in the team at all, tried once on its own.
|
|
2716
|
+
IF refused IS NULL THEN
|
|
2717
|
+
BEGIN
|
|
2718
|
+
EXECUTE join_;
|
|
2719
|
+
RAISE EXCEPTION 'kryptheon: undoing the join' USING ERRCODE = 'KN001';
|
|
2720
|
+
EXCEPTION
|
|
2721
|
+
WHEN SQLSTATE 'KN001' THEN
|
|
2722
|
+
NULL;
|
|
2723
|
+
WHEN OTHERS THEN
|
|
2724
|
+
refused := SQLERRM;
|
|
2725
|
+
END;
|
|
2726
|
+
END IF;
|
|
2727
|
+
IF refused IS NOT NULL THEN
|
|
2728
|
+
-- Said once, on the table that would not take them.
|
|
2729
|
+
blocked := blocked || jsonb_build_array(jsonb_build_object(
|
|
2730
|
+
'table', member->'table'->>'name',
|
|
2731
|
+
'key', 'role:' || (member->'table'->>'name') || ':' || (member->>'lowest'),
|
|
2732
|
+
'why', 'as a "' || (member->>'lowest') || '" in ' || (member->'table'->>'name') ||
|
|
2733
|
+
': I could not add one: ' || refused));
|
|
2734
|
+
CONTINUE;
|
|
2735
|
+
END IF;
|
|
2736
|
+
|
|
2737
|
+
FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
|
|
2738
|
+
CONTINUE WHEN NOT EXISTS (SELECT 1 FROM jsonb_array_elements(coalesce(seeded, '[]'::jsonb)) s
|
|
2739
|
+
WHERE s->>'table' = tab->>'name');
|
|
2740
|
+
CONTINUE WHEN done ? (tab->>'name');
|
|
2741
|
+
done := done || to_jsonb(tab->>'name');
|
|
2742
|
+
SELECT (s->>'attempt')::integer INTO shape
|
|
2743
|
+
FROM jsonb_array_elements(coalesce(seeded, '[]'::jsonb)) s
|
|
2744
|
+
WHERE s->>'table' = tab->>'name';
|
|
2745
|
+
shape := coalesce(shape, 0);
|
|
2746
|
+
finding_key := 'role:' || (tab->>'name') || ':' || (member->>'lowest');
|
|
2747
|
+
owner := __KN__.owner_column(tab);
|
|
2748
|
+
|
|
2749
|
+
-- A table that names the team itself, and the team it is to name.
|
|
2750
|
+
SELECT k INTO to_team
|
|
2751
|
+
FROM jsonb_array_elements(__KN__.foreign_keys(tab) || __KN__.implied_keys(tab, tables))
|
|
2752
|
+
WITH ORDINALITY AS e(k, pos)
|
|
2753
|
+
WHERE k->>'refTable' = parent
|
|
2754
|
+
AND jsonb_array_length(k->'columns') = jsonb_array_length(parent_key->'refColumns')
|
|
2755
|
+
ORDER BY pos LIMIT 1;
|
|
2756
|
+
IF NOT FOUND THEN to_team := NULL; END IF;
|
|
2757
|
+
pointed := '{}'::jsonb;
|
|
2758
|
+
IF to_team IS NOT NULL THEN
|
|
2759
|
+
FOR i IN 0 .. jsonb_array_length(to_team->'columns') - 1 LOOP
|
|
2760
|
+
pointed := jsonb_set(pointed, ARRAY[to_team->'columns'->>i], coalesce(team->(
|
|
2761
|
+
SELECT parent_key->'columns'->>((p.pos - 1)::integer)
|
|
2762
|
+
FROM jsonb_array_elements_text(parent_key->'refColumns') WITH ORDINALITY AS p(c, pos)
|
|
2763
|
+
WHERE p.c = to_team->'refColumns'->>i), 'null'::jsonb));
|
|
2764
|
+
END LOOP;
|
|
2765
|
+
END IF;
|
|
2766
|
+
|
|
2767
|
+
-- Never their own rows, and only the team they joined, where the table
|
|
2768
|
+
-- says which team a row is in.
|
|
2769
|
+
where_ := ARRAY[]::text[];
|
|
2770
|
+
IF owner IS NOT NULL THEN
|
|
2771
|
+
where_ := where_ || (__KN__.always_quote(owner) || ' IS DISTINCT FROM ' || quote_literal(__KN__.user_b()));
|
|
2772
|
+
END IF;
|
|
2773
|
+
FOR name IN SELECT jsonb_object_keys(pointed) ORDER BY 1 LOOP
|
|
2774
|
+
where_ := where_ || (__KN__.always_quote(name) || ' = ' || quote_literal(pointed->>name));
|
|
2775
|
+
END LOOP;
|
|
2776
|
+
not_theirs := CASE WHEN array_length(where_, 1) IS NULL THEN ''
|
|
2777
|
+
ELSE ' WHERE ' || array_to_string(where_, ' AND ') END;
|
|
2778
|
+
target := __KN__.always_quote(source) || '.' || __KN__.always_quote(tab->>'name');
|
|
2779
|
+
column_ := __KN__.always_quote(__KN__.first_writable(tab));
|
|
2780
|
+
moves := jsonb_build_array(
|
|
2781
|
+
jsonb_build_object('what', 'change',
|
|
2782
|
+
'statement', 'UPDATE ' || target || ' SET ' || column_ || ' = ' || column_ || not_theirs),
|
|
2783
|
+
jsonb_build_object('what', 'delete', 'statement', 'DELETE FROM ' || target || not_theirs));
|
|
2784
|
+
|
|
2785
|
+
-- Adding a row to the team, said only of a role whose own name says it
|
|
2786
|
+
-- may only look, and written under the other person's name: a rule that
|
|
2787
|
+
-- lets a member add a row only as themselves is them speaking for
|
|
2788
|
+
-- themselves.
|
|
2789
|
+
IF __KN__.look_only(member->>'lowest') AND to_team IS NOT NULL THEN
|
|
2790
|
+
BEGIN
|
|
2791
|
+
row_ := __KN__.row_for(source, tab, __KN__.user_a(), '11', pointed, shape);
|
|
2792
|
+
moves := jsonb_build_array(jsonb_build_object('what', 'add',
|
|
2793
|
+
'statement', __KN__.insert_statement(source, tab->>'name', row_))) || moves;
|
|
2794
|
+
EXCEPTION WHEN OTHERS THEN
|
|
2795
|
+
NULL;
|
|
2796
|
+
END;
|
|
2797
|
+
END IF;
|
|
2798
|
+
|
|
2799
|
+
can := '[]'::jsonb;
|
|
2800
|
+
changed := '{}'::jsonb;
|
|
2801
|
+
stuck := NULL;
|
|
2802
|
+
FOR move IN SELECT * FROM jsonb_array_elements(moves) LOOP
|
|
2803
|
+
before_ := __KN__.try_write('authenticated', __KN__.user_b(), move->>'statement');
|
|
2804
|
+
after_ := __KN__.try_write_joined('authenticated', __KN__.user_b(), move->>'statement', join_);
|
|
2805
|
+
was := __KN__.rows_moved(before_);
|
|
2806
|
+
now_ := __KN__.rows_moved(after_);
|
|
2807
|
+
IF was IS NULL OR now_ IS NULL THEN
|
|
2808
|
+
stuck := CASE WHEN now_ IS NULL THEN after_->>'why' ELSE before_->>'why' END;
|
|
2809
|
+
ELSIF now_ > was THEN
|
|
2810
|
+
can := can || to_jsonb(move->>'what');
|
|
2811
|
+
changed := jsonb_set(changed, ARRAY[move->>'what'], to_jsonb(now_ - was));
|
|
2812
|
+
END IF;
|
|
2813
|
+
END LOOP;
|
|
2814
|
+
|
|
2815
|
+
IF jsonb_array_length(can) = 0 AND stuck IS NOT NULL THEN
|
|
2816
|
+
blocked := blocked || jsonb_build_array(jsonb_build_object(
|
|
2817
|
+
'table', tab->>'name', 'key', finding_key,
|
|
2818
|
+
'why', 'as a "' || (member->>'lowest') || '" in ' || (member->'table'->>'name') || ': ' || stuck));
|
|
2819
|
+
ELSE
|
|
2820
|
+
completed := completed || to_jsonb(finding_key);
|
|
2821
|
+
IF jsonb_array_length(can) > 0 THEN
|
|
2822
|
+
findings := findings || jsonb_build_array(jsonb_build_object(
|
|
2823
|
+
'kind', 'role',
|
|
2824
|
+
'table', tab->>'name',
|
|
2825
|
+
'who', member->>'lowest',
|
|
2826
|
+
'via', member->'table'->>'name',
|
|
2827
|
+
'roleColumn', member->>'role',
|
|
2828
|
+
'parent', parent,
|
|
2829
|
+
'can', can,
|
|
2830
|
+
'changed', changed,
|
|
2831
|
+
'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c), '[]'::jsonb),
|
|
2832
|
+
'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
|
|
2833
|
+
END IF;
|
|
2834
|
+
END IF;
|
|
2835
|
+
|
|
2836
|
+
-- Reading what a member should not see, said only of a table holding a
|
|
2837
|
+
-- secret. Every role not in charge, from the lowest up; the first that
|
|
2838
|
+
-- can read is the one named.
|
|
2839
|
+
secrets := __KN__.secret_columns(tab);
|
|
2840
|
+
CONTINUE WHEN jsonb_array_length(secrets) = 0;
|
|
2841
|
+
look := 'SELECT 1 FROM ' || target || not_theirs;
|
|
2842
|
+
was := __KN__.rows_moved(__KN__.try_write('authenticated', __KN__.user_b(), look));
|
|
2843
|
+
tried := '[]'::jsonb;
|
|
2844
|
+
FOR role_ IN SELECT jsonb_array_elements_text(member->'ladder') LOOP
|
|
2845
|
+
read_key := 'teamread:' || (tab->>'name') || ':' || role_;
|
|
2846
|
+
IF joins ? role_ THEN
|
|
2847
|
+
after_ := __KN__.try_write_joined('authenticated', __KN__.user_b(), look, joins->>role_);
|
|
2848
|
+
ELSE
|
|
2849
|
+
after_ := jsonb_build_object('ok', false, 'count', 0, 'why', 'I could not add one');
|
|
2850
|
+
END IF;
|
|
2851
|
+
now_ := __KN__.rows_moved(after_);
|
|
2852
|
+
IF was IS NULL OR now_ IS NULL THEN
|
|
2853
|
+
blocked := blocked || jsonb_build_array(jsonb_build_object(
|
|
2854
|
+
'table', tab->>'name', 'key', read_key,
|
|
2855
|
+
'why', 'as a "' || role_ || '" in ' || (member->'table'->>'name') || ': ' ||
|
|
2856
|
+
CASE WHEN now_ IS NULL THEN after_->>'why' ELSE 'reading it failed before joining' END));
|
|
2857
|
+
EXIT;
|
|
2858
|
+
END IF;
|
|
2859
|
+
completed := completed || to_jsonb(read_key);
|
|
2860
|
+
IF now_ > was THEN
|
|
2861
|
+
findings := findings || jsonb_build_array(jsonb_build_object(
|
|
2862
|
+
'kind', 'teamread',
|
|
2863
|
+
'table', tab->>'name',
|
|
2864
|
+
'who', role_,
|
|
2865
|
+
'via', member->'table'->>'name',
|
|
2866
|
+
'roleColumn', member->>'role',
|
|
2867
|
+
'parent', parent,
|
|
2868
|
+
'readable', now_ - was,
|
|
2869
|
+
'secrets', secrets,
|
|
2870
|
+
'below', tried,
|
|
2871
|
+
'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c), '[]'::jsonb),
|
|
2872
|
+
'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
|
|
2873
|
+
EXIT;
|
|
2874
|
+
END IF;
|
|
2875
|
+
tried := tried || to_jsonb(role_);
|
|
2876
|
+
END LOOP;
|
|
2877
|
+
END LOOP;
|
|
2878
|
+
END LOOP;
|
|
2879
|
+
|
|
2880
|
+
RETURN jsonb_build_object('findings', findings, 'completed', completed, 'blocked', blocked);
|
|
2881
|
+
END $$;
|
|
2882
|
+
|
|
2319
2883
|
-- --------------------------------------------------------------------------
|
|
2320
2884
|
-- The interruption attack: can a half-finished write survive?
|
|
2321
2885
|
--
|
|
@@ -2776,6 +3340,7 @@ DECLARE
|
|
|
2776
3340
|
sown jsonb;
|
|
2777
3341
|
seen jsonb;
|
|
2778
3342
|
wrote jsonb;
|
|
3343
|
+
ranked jsonb;
|
|
2779
3344
|
stranded jsonb;
|
|
2780
3345
|
raced jsonb;
|
|
2781
3346
|
findings jsonb := '[]'::jsonb;
|
|
@@ -2829,10 +3394,11 @@ BEGIN
|
|
|
2829
3394
|
|
|
2830
3395
|
seen := __KN__.impersonate(copy_name, theirs || views);
|
|
2831
3396
|
wrote := __KN__.tamper(copy_name, theirs, sown->'seeded', copy_plan->'policies');
|
|
3397
|
+
ranked := __KN__.teammate(copy_name, theirs, sown->'seeded');
|
|
2832
3398
|
stranded := __KN__.orphan(copy_name, theirs, sown->'seeded');
|
|
2833
3399
|
raced := __KN__.collide(copy_name, theirs, copy_plan->'indexes');
|
|
2834
3400
|
|
|
2835
|
-
findings := (seen->'findings') || (wrote->'findings') || (stranded->'findings');
|
|
3401
|
+
findings := (seen->'findings') || (wrote->'findings') || (ranked->'findings') || (stranded->'findings');
|
|
2836
3402
|
|
|
2837
3403
|
-- A rule that looks itself up breaks the app for whoever it fails for.
|
|
2838
3404
|
-- One finding per table at fault, however many reads ran into it; the
|
|
@@ -2851,6 +3417,7 @@ BEGIN
|
|
|
2851
3417
|
-- fixed from one whose attack simply did not run this time.
|
|
2852
3418
|
attempted := coalesce(seen->'completed', '[]'::jsonb)
|
|
2853
3419
|
|| coalesce(wrote->'completed', '[]'::jsonb)
|
|
3420
|
+
|| coalesce(ranked->'completed', '[]'::jsonb)
|
|
2854
3421
|
|| coalesce(stranded->'completed', '[]'::jsonb);
|
|
2855
3422
|
|
|
2856
3423
|
-- And everything that was not. Said out loud, never left out: a table
|
|
@@ -2858,6 +3425,7 @@ BEGIN
|
|
|
2858
3425
|
not_checked := coalesce(sown->'skipped', '[]'::jsonb)
|
|
2859
3426
|
|| coalesce(seen->'blocked', '[]'::jsonb)
|
|
2860
3427
|
|| coalesce(wrote->'blocked', '[]'::jsonb)
|
|
3428
|
+
|| coalesce(ranked->'blocked', '[]'::jsonb)
|
|
2861
3429
|
|| coalesce(stranded->'notTried', '[]'::jsonb)
|
|
2862
3430
|
|| coalesce(raced->'notTried', '[]'::jsonb);
|
|
2863
3431
|
|