kryptheon-night 0.1.26 → 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 CHANGED
@@ -74,7 +74,7 @@ whether they are meant:
74
74
 
75
75
  | check | the question |
76
76
  |---|---|
77
- | **Lowest role** | can a viewer in a team change what is in it? (tried on the copy, undone) |
77
+ | **Lowest role** | can a viewer in a team change or add to what is in it, and which roles can read its tokens, keys and payloads? (tried on the copy, undone) |
78
78
  | **Storage** | is a bucket public although a rule says who may read it? (settings only, never a file) |
79
79
  | **Open functions** | can a visitor with no account call a function that skips the rules? |
80
80
 
package/attack.js CHANGED
@@ -204,6 +204,48 @@ function allowedByCheck(table, columnName) {
204
204
  return found;
205
205
  }
206
206
 
207
+ /**
208
+ * Sets of columns where exactly one may be filled in.
209
+ *
210
+ * Found on a blind test (HelixOps): documents belonged to a work order or to
211
+ * an inspection, never both, written
212
+ * CHECK ((work_order_id IS NOT NULL)::int + (inspection_id IS NOT NULL)::int = 1)
213
+ * Both parents were there, so the seeder pointed the row at both, the CHECK
214
+ * refused every attempt, and the table was never tested at all.
215
+ *
216
+ * Two spellings are read - that sum, and num_nonnulls(a, b, ...) = 1 - and
217
+ * only when the constraint says nothing else. A CHECK with anything more in it
218
+ * is left alone rather than half understood.
219
+ */
220
+ function exactlyOne(table) {
221
+ const groups = [];
222
+ const name = '("(?:[^"]|"")+"|[A-Za-z_][A-Za-z0-9_$]*)';
223
+ const unquote = (text) => (text.charAt(0) === '"' ? text.slice(1, -1).split('""').join('"') : text);
224
+ for (const constraint of table.constraints || []) {
225
+ if (constraint.kind !== 'c') continue;
226
+ const definition = String(constraint.definition || '');
227
+ let columns = [];
228
+ let rest = definition;
229
+ const summed = new RegExp('\\(\\(' + name + ' IS NOT NULL\\)\\)::integer', 'gi');
230
+ let match;
231
+ while ((match = summed.exec(definition)) !== null) columns.push(unquote(match[1]));
232
+ rest = rest.replace(summed, '').replace(/\+/g, '');
233
+ if (!columns.length) {
234
+ const counted = /num_nonnulls\(([^()]*)\)/i.exec(definition);
235
+ if (counted) {
236
+ const listed = new RegExp(name, 'g');
237
+ let one;
238
+ while ((one = listed.exec(counted[1])) !== null) columns.push(unquote(one[1]));
239
+ rest = rest.replace(counted[0], '');
240
+ }
241
+ }
242
+ // Nothing may be left but the CHECK itself, brackets and "= 1".
243
+ if (columns.length < 2 || rest.replace(/[()\s]/g, '').toUpperCase() !== 'CHECK=1') continue;
244
+ groups.push(columns);
245
+ }
246
+ return groups;
247
+ }
248
+
207
249
  /**
208
250
  * The foreign keys on a table, read out of Postgres's own wording.
209
251
  *
@@ -395,12 +437,28 @@ async function rowFor(client, schema, table, person, distinct, overrides, attemp
395
437
  const partOfKey = new Set();
396
438
  for (const key of foreignKeys(table)) key.columns.forEach((name) => partOfKey.add(name));
397
439
 
440
+ // Where exactly one of a set may be filled in: the one already chosen, or
441
+ // else the first that can be - a key needs a parent row to point at. The
442
+ // rest are written as nothing, whatever their default.
443
+ const chosen = (name) => Object.prototype.hasOwnProperty.call(forced, name);
444
+ const onlyOne = new Map();
445
+ for (const group of exactlyOne(table)) {
446
+ const kept = group.find(chosen) || group.find((name) => !partOfKey.has(name) || borrowed.has(name));
447
+ for (const name of group) onlyOne.set(name, name === kept);
448
+ }
449
+
398
450
  for (const column of table.columns) {
399
451
  if (Object.prototype.hasOwnProperty.call(forced, column.name)) {
400
452
  columns.push(column.name);
401
453
  values.push(forced[column.name]);
402
454
  continue;
403
455
  }
456
+ if (onlyOne.get(column.name) === false) {
457
+ columns.push(column.name);
458
+ values.push(null);
459
+ continue;
460
+ }
461
+ const mustFill = onlyOne.get(column.name) === true;
404
462
  if (column.name === owner) {
405
463
  columns.push(column.name);
406
464
  values.push(person);
@@ -436,9 +494,11 @@ async function rowFor(client, schema, table, person, distinct, overrides, attemp
436
494
  values.push(borrowed.get(column.name));
437
495
  continue;
438
496
  }
439
- // Anything with a default can supply its own value.
497
+ // Anything with a default can supply its own value. A column that may be
498
+ // empty is left empty - unless it is the one of a set that has to be
499
+ // filled in.
440
500
  if (column.default_expr) continue;
441
- if (!column.not_null) continue;
501
+ if (!column.not_null && !mustFill) continue;
442
502
  columns.push(column.name);
443
503
  // A CHECK that lists what it will accept beats anything invented here.
444
504
  const allowed = allowedByCheck(table, column.name);
@@ -766,6 +826,7 @@ module.exports = {
766
826
  dependencyOrder: dependencyOrder,
767
827
  valueFor: valueFor,
768
828
  allowedByCheck: allowedByCheck,
829
+ exactlyOne: exactlyOne,
769
830
  rowFor: rowFor,
770
831
  existingRow: existingRow,
771
832
  insertRow: insertRow,
@@ -648,7 +648,9 @@ async function main() {
648
648
  if (result.findings.length) scanner.report(result);
649
649
  else scanner.notTestedLines(result).forEach(line);
650
650
  scanner.saveRun(file, result);
651
- process.exitCode = result.stopped ? 2 : verdict.allClear ? 0 : 1;
651
+ // Two different databases: the verdict is a comparison, so the exit
652
+ // code is this run's own answer, the same as a first run.
653
+ process.exitCode = result.stopped ? 2 : verdict.elsewhere ? scanner.exitCodeFor(result) : verdict.allClear ? 0 : 1;
652
654
  }
653
655
 
654
656
  // And then the only other thing worth doing, offered rather than
package/engine.sql CHANGED
@@ -1625,6 +1625,54 @@ END $$;
1625
1625
  * and `implied`, the keys the app never declared, so a `workspace_id` with no
1626
1626
  * foreign key still names a workspace that is there.
1627
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
+
1628
1676
  CREATE OR REPLACE FUNCTION __KN__.row_for(
1629
1677
  source text, tab jsonb, person text, distinct_ text, overrides jsonb, attempt integer,
1630
1678
  seeding jsonb DEFAULT NULL)
@@ -1636,6 +1684,10 @@ DECLARE
1636
1684
  values_ jsonb := '[]'::jsonb;
1637
1685
  borrowed jsonb := '{}'::jsonb;
1638
1686
  part_of_key jsonb := '[]'::jsonb;
1687
+ only_one jsonb := '{}'::jsonb;
1688
+ group_ jsonb;
1689
+ kept text;
1690
+ must_fill boolean;
1639
1691
  key jsonb;
1640
1692
  row_ jsonb;
1641
1693
  col jsonb;
@@ -1664,6 +1716,22 @@ BEGIN
1664
1716
  part_of_key := part_of_key || (key->'columns');
1665
1717
  END LOOP;
1666
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
+
1667
1735
  FOR col IN SELECT * FROM jsonb_array_elements(tab->'columns') LOOP
1668
1736
  name := col->>'name';
1669
1737
 
@@ -1673,6 +1741,13 @@ BEGIN
1673
1741
  CONTINUE;
1674
1742
  END IF;
1675
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
+
1676
1751
  IF name = owner THEN
1677
1752
  columns := columns || to_jsonb(name);
1678
1753
  values_ := values_ || jsonb_build_array(to_jsonb(person));
@@ -1712,9 +1787,11 @@ BEGIN
1712
1787
  values_ := values_ || jsonb_build_array(borrowed->name);
1713
1788
  CONTINUE;
1714
1789
  END IF;
1715
- -- 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.
1716
1793
  CONTINUE WHEN col->>'default_expr' IS NOT NULL;
1717
- CONTINUE WHEN NOT (col->>'not_null')::boolean;
1794
+ CONTINUE WHEN NOT (col->>'not_null')::boolean AND NOT must_fill;
1718
1795
 
1719
1796
  columns := columns || to_jsonb(name);
1720
1797
  -- A CHECK that lists what it will accept beats anything invented here.
@@ -2414,12 +2491,36 @@ BEGIN
2414
2491
  CONTINUE WHEN labels IS NULL OR jsonb_array_length(labels) < 2;
2415
2492
  lowest := __KN__.lowest_role(labels);
2416
2493
  IF lowest IS NOT NULL THEN
2417
- RETURN jsonb_build_object('column', col->>'name', 'lowest', lowest);
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));
2418
2501
  END IF;
2419
2502
  END LOOP;
2420
2503
  RETURN NULL;
2421
2504
  END $$;
2422
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
+
2423
2524
  /* Whether a column is a key on its own: one row per person, at most. */
2424
2525
  CREATE OR REPLACE FUNCTION __KN__.alone_unique(tab jsonb, column_name text)
2425
2526
  RETURNS boolean LANGUAGE sql IMMUTABLE AS $$
@@ -2461,7 +2562,8 @@ BEGIN
2461
2562
  AND NOT (k->'columns' ? person);
2462
2563
  CONTINUE WHEN jsonb_array_length(keys) = 0 OR __KN__.alone_unique(tab, person);
2463
2564
  found := found || jsonb_build_array(jsonb_build_object(
2464
- 'table', tab, 'person', person, 'role', role_->>'column', 'lowest', role_->>'lowest', 'keys', keys));
2565
+ 'table', tab, 'person', person, 'role', role_->>'column', 'lowest', role_->>'lowest',
2566
+ 'ladder', role_->'ladder', 'keys', keys));
2465
2567
  END LOOP;
2466
2568
  -- A table named for members first: it is the one a team rule asks.
2467
2569
  SELECT coalesce(jsonb_agg(m ORDER BY (m->'table'->>'name') !~* 'member', at), '[]'::jsonb) INTO found
@@ -2533,20 +2635,27 @@ DECLARE
2533
2635
  shape integer;
2534
2636
  at text;
2535
2637
  theirs jsonb;
2638
+ team jsonb;
2536
2639
  overrides jsonb;
2640
+ joins jsonb;
2537
2641
  key jsonb;
2538
2642
  name text;
2539
2643
  parent text;
2540
- as_ text;
2644
+ parent_key jsonb;
2645
+ to_team jsonb;
2646
+ pointed jsonb;
2541
2647
  row_ jsonb;
2542
2648
  join_ text;
2543
2649
  refused text;
2544
2650
  owner text;
2651
+ where_ text[];
2545
2652
  not_theirs text;
2546
2653
  target text;
2547
2654
  column_ text;
2548
2655
  finding_key text;
2549
- move record;
2656
+ read_key text;
2657
+ moves jsonb;
2658
+ move jsonb;
2550
2659
  was bigint;
2551
2660
  now_ bigint;
2552
2661
  before_ jsonb;
@@ -2554,6 +2663,11 @@ DECLARE
2554
2663
  can jsonb;
2555
2664
  changed jsonb;
2556
2665
  stuck text;
2666
+ secrets jsonb;
2667
+ look text;
2668
+ role_ text;
2669
+ tried jsonb;
2670
+ i integer;
2557
2671
  BEGIN
2558
2672
  FOR member IN SELECT * FROM jsonb_array_elements(__KN__.memberships(tables)) LOOP
2559
2673
  SELECT (s->>'attempt')::integer INTO shape
@@ -2571,37 +2685,52 @@ BEGIN
2571
2685
  CONTINUE WHEN theirs IS NULL;
2572
2686
 
2573
2687
  -- Their team, as the first person's own membership names it.
2574
- overrides := jsonb_build_object(member->>'role', member->>'lowest');
2688
+ team := '{}'::jsonb;
2575
2689
  FOR key IN SELECT * FROM jsonb_array_elements(member->'keys') LOOP
2576
2690
  FOR name IN SELECT jsonb_array_elements_text(key->'columns') LOOP
2577
2691
  IF name <> member->>'person' THEN
2578
- overrides := jsonb_set(overrides, ARRAY[name], coalesce(theirs->name, 'null'::jsonb));
2692
+ team := jsonb_set(team, ARRAY[name], coalesce(theirs->name, 'null'::jsonb));
2579
2693
  END IF;
2580
2694
  END LOOP;
2581
2695
  END LOOP;
2582
- parent := member->'keys'->0->>'refTable';
2583
- as_ := 'as a "' || (member->>'lowest') || '" in ' || (member->'table'->>'name');
2696
+ parent_key := member->'keys'->0;
2697
+ parent := parent_key->>'refTable';
2584
2698
 
2585
- -- Whether they can be put in the team at all, tried once on its own.
2699
+ -- Putting the other person in that team, as each role in turn. Built up
2700
+ -- front, before anybody changes role.
2701
+ joins := '{}'::jsonb;
2586
2702
  refused := NULL;
2587
- BEGIN
2588
- row_ := __KN__.row_for(source, member->'table', __KN__.user_b(), '9', overrides, shape);
2589
- join_ := __KN__.insert_statement(source, member->'table'->>'name', row_);
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
2590
2717
  BEGIN
2591
2718
  EXECUTE join_;
2592
2719
  RAISE EXCEPTION 'kryptheon: undoing the join' USING ERRCODE = 'KN001';
2593
- EXCEPTION WHEN SQLSTATE 'KN001' THEN
2594
- NULL;
2720
+ EXCEPTION
2721
+ WHEN SQLSTATE 'KN001' THEN
2722
+ NULL;
2723
+ WHEN OTHERS THEN
2724
+ refused := SQLERRM;
2595
2725
  END;
2596
- EXCEPTION WHEN OTHERS THEN
2597
- refused := SQLERRM;
2598
- END;
2726
+ END IF;
2599
2727
  IF refused IS NOT NULL THEN
2600
2728
  -- Said once, on the table that would not take them.
2601
2729
  blocked := blocked || jsonb_build_array(jsonb_build_object(
2602
2730
  'table', member->'table'->>'name',
2603
2731
  'key', 'role:' || (member->'table'->>'name') || ':' || (member->>'lowest'),
2604
- 'why', as_ || ': I could not add one: ' || refused));
2732
+ 'why', 'as a "' || (member->>'lowest') || '" in ' || (member->'table'->>'name') ||
2733
+ ': I could not add one: ' || refused));
2605
2734
  CONTINUE;
2606
2735
  END IF;
2607
2736
 
@@ -2610,54 +2739,141 @@ BEGIN
2610
2739
  WHERE s->>'table' = tab->>'name');
2611
2740
  CONTINUE WHEN done ? (tab->>'name');
2612
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);
2613
2746
  finding_key := 'role:' || (tab->>'name') || ':' || (member->>'lowest');
2614
2747
  owner := __KN__.owner_column(tab);
2615
- -- Never their own rows: changing what you made yourself is not a role
2616
- -- question, and the row that put them in the team is theirs.
2617
- not_theirs := CASE WHEN owner IS NULL THEN ''
2618
- ELSE ' WHERE ' || __KN__.always_quote(owner) || ' IS DISTINCT FROM ' || quote_literal(__KN__.user_b()) END;
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;
2619
2778
  target := __KN__.always_quote(source) || '.' || __KN__.always_quote(tab->>'name');
2620
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
+
2621
2799
  can := '[]'::jsonb;
2622
2800
  changed := '{}'::jsonb;
2623
2801
  stuck := NULL;
2624
- FOR move IN
2625
- SELECT * FROM (VALUES
2626
- (1, 'change', 'UPDATE ' || target || ' SET ' || column_ || ' = ' || column_ || not_theirs),
2627
- (2, 'delete', 'DELETE FROM ' || target || not_theirs)) AS m(n, what, statement)
2628
- ORDER BY n
2629
- LOOP
2630
- before_ := __KN__.try_write('authenticated', __KN__.user_b(), move.statement);
2631
- after_ := __KN__.try_write_joined('authenticated', __KN__.user_b(), move.statement, join_);
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_);
2632
2805
  was := __KN__.rows_moved(before_);
2633
2806
  now_ := __KN__.rows_moved(after_);
2634
2807
  IF was IS NULL OR now_ IS NULL THEN
2635
2808
  stuck := CASE WHEN now_ IS NULL THEN after_->>'why' ELSE before_->>'why' END;
2636
2809
  ELSIF now_ > was THEN
2637
- can := can || to_jsonb(move.what);
2638
- changed := jsonb_set(changed, ARRAY[move.what], to_jsonb(now_ - was));
2810
+ can := can || to_jsonb(move->>'what');
2811
+ changed := jsonb_set(changed, ARRAY[move->>'what'], to_jsonb(now_ - was));
2639
2812
  END IF;
2640
2813
  END LOOP;
2641
2814
 
2642
2815
  IF jsonb_array_length(can) = 0 AND stuck IS NOT NULL THEN
2643
2816
  blocked := blocked || jsonb_build_array(jsonb_build_object(
2644
- 'table', tab->>'name', 'key', finding_key, 'why', as_ || ': ' || stuck));
2645
- CONTINUE;
2646
- END IF;
2647
- completed := completed || to_jsonb(finding_key);
2648
- IF jsonb_array_length(can) > 0 THEN
2649
- findings := findings || jsonb_build_array(jsonb_build_object(
2650
- 'kind', 'role',
2651
- 'table', tab->>'name',
2652
- 'who', member->>'lowest',
2653
- 'via', member->'table'->>'name',
2654
- 'roleColumn', member->>'role',
2655
- 'parent', parent,
2656
- 'can', can,
2657
- 'changed', changed,
2658
- 'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c), '[]'::jsonb),
2659
- 'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
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;
2660
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;
2661
2877
  END LOOP;
2662
2878
  END LOOP;
2663
2879
 
package/finding.js CHANGED
@@ -89,6 +89,12 @@ function listOf(items) {
89
89
  }
90
90
 
91
91
  /** One of a table's things: workspaces -> workspace, companies -> company. */
92
+ /** The same, with its article: a workspace, an organization. */
93
+ function aOne(table) {
94
+ const one = oneOf(table);
95
+ return (/^[aeiou]/i.test(one) ? 'an ' : 'a ') + one;
96
+ }
97
+
92
98
  function oneOf(table) {
93
99
  return String(table || 'team').replace(/ies$/, 'y').replace(/s$/, '');
94
100
  }
@@ -110,7 +116,7 @@ function severityOf(finding, contents) {
110
116
  // because the app is broken for whoever it fails for, but not a break-in.
111
117
  return 'HIGH';
112
118
  }
113
- if (finding.kind === 'role' || finding.kind === 'bucket') {
119
+ if (finding.kind === 'role' || finding.kind === 'teamread' || finding.kind === 'bucket') {
114
120
  // Something to check, never a confirmed break: whether a viewer may edit,
115
121
  // or a bucket may be public, is the owner's decision. So never CRITICAL.
116
122
  return 'HIGH';
@@ -345,9 +351,13 @@ function headlineFor(finding) {
345
351
  if (finding.kind === 'role') {
346
352
  const order = ['delete', 'change', 'add'];
347
353
  const does = order.filter((what) => (finding.can || []).includes(what)).map((what) => WRITE_WORDS[what]);
348
- return 'A "' + finding.who + '" in a ' + oneOf(finding.parent) + ' can ' + listOf(does) + ' your ' +
354
+ return 'A "' + finding.who + '" in ' + aOne(finding.parent) + ' can ' + listOf(does) + ' your ' +
349
355
  finding.table + ' table.';
350
356
  }
357
+ if (finding.kind === 'teamread') {
358
+ return 'A "' + finding.who + '" in ' + aOne(finding.parent) + ' can read your ' + finding.table +
359
+ ' table, which holds ' + listOf(finding.secrets || []) + '.';
360
+ }
351
361
  if (finding.kind === 'bucket') {
352
362
  return 'Your ' + finding.table + ' storage bucket is public, but a rule says only some people may read it.';
353
363
  }
@@ -439,19 +449,36 @@ function bodyFor(finding, contents) {
439
449
  // edit is a hole in one app and the design of another.
440
450
  const did = [];
441
451
  const rows = (n) => n + (n === 1 ? ' row' : ' rows');
452
+ if ((finding.can || []).includes('add')) did.push('added ' + rows((finding.changed || {}).add || 1));
442
453
  if ((finding.can || []).includes('change')) did.push('changed ' + rows((finding.changed || {}).change || 1));
443
454
  if ((finding.can || []).includes('delete')) did.push('deleted ' + rows((finding.changed || {}).delete || 1));
444
455
  const place = oneOf(finding.parent);
445
456
  return (
446
457
  'I put one of my fake people into the other one\'s ' + place + ' as a "' + finding.who +
447
458
  '" - the lowest role in ' + finding.via + '.' + finding.roleColumn + ' - and, signed in as them, I ' +
448
- listOf(did) + ' in ' + finding.table + ' that belonged to that ' + place + ', on a copy of your app. ' +
459
+ listOf(did) + ' in ' + finding.table + ' for that ' + place + ', on a copy of your app. ' +
449
460
  'Before joining, they could not. Every one of those was undone straight away. Whether a "' + finding.who +
450
461
  '" may do this is your decision: if it is meant to, nothing needs fixing; if a "' + finding.who +
451
462
  '" should only look, the rules on this table let them do more.'
452
463
  );
453
464
  }
454
465
 
466
+ if (finding.kind === 'teamread') {
467
+ // Said as what was read and by whom, and which roles below it could not:
468
+ // a dispatcher seeing a key may be the design, a viewer rarely is.
469
+ const place = oneOf(finding.parent);
470
+ const n = finding.readable || 1;
471
+ const below = (finding.below || []).map((r) => '"' + r + '"');
472
+ return (
473
+ 'I put one of my fake people into the other one\'s ' + place + ' as a "' + finding.who + '" and, signed ' +
474
+ 'in as them, read ' + n + (n === 1 ? ' row' : ' rows') + ' of ' + finding.table + ' for that ' + place +
475
+ ', on a copy of your app - including ' + listOf(finding.secrets || []) + '. Before joining, they could not.' +
476
+ (below.length ? ' As ' + listOf(below).replace(/ and /, ' or ') + ' they could not read it either.' : '') +
477
+ ' Nothing was changed. Whether a "' + finding.who + '" should see these is your decision: if not, a read ' +
478
+ 'rule on this table lets them.'
479
+ );
480
+ }
481
+
455
482
  if (finding.kind === 'bucket') {
456
483
  // Said as what was read, never as a file taken: no file was opened.
457
484
  return (
@@ -747,9 +774,9 @@ function fixPromptFor(finding) {
747
774
  ? [
748
775
  'My app may have a permissions problem - please check it rather than assume it.',
749
776
  '',
750
- 'In my database, a member of a ' + oneOf(finding.parent) + ' whose role in "' + finding.via + '"."' +
777
+ 'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
751
778
  finding.roleColumn + '" is "' + finding.who + '" can ' +
752
- listOf((finding.can || []).map((what) => ({ change: 'change', delete: 'delete' })[what])) +
779
+ listOf((finding.can || []).map((what) => ({ add: 'add', change: 'change', delete: 'delete' })[what])) +
753
780
  ' rows of that ' + oneOf(finding.parent) + ' in the "' + finding.table + '" table. This was proved ' +
754
781
  'on a copy of the database: a "' + finding.who + '" was added, and the writes went through.',
755
782
  '',
@@ -764,6 +791,25 @@ function fixPromptFor(finding) {
764
791
  '',
765
792
  'Then check every other table where members can write for the same thing.',
766
793
  ]
794
+ : finding.kind === 'teamread'
795
+ ? [
796
+ 'My app may have a permissions problem - please check it rather than assume it.',
797
+ '',
798
+ 'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
799
+ finding.roleColumn + '" is "' + finding.who + '" can read the "' + finding.table + '" table of that ' +
800
+ oneOf(finding.parent) + ', including ' + listOf((finding.secrets || []).map((c) => '"' + c + '"')) +
801
+ '. This was proved on a copy of the database: a "' + finding.who + '" was added, and the rows came back.',
802
+ '',
803
+ 'First tell me: should a "' + finding.who + '" see these? If yes, change nothing.',
804
+ '',
805
+ 'If not, make the rules on "' + finding.table + '" that cover SELECT or ALL check the member\'s role as ' +
806
+ 'well as membership. Postgres lets a read through if ANY permissive policy allows it, so a strict rule ' +
807
+ 'beside a loose one does nothing: list every such policy on "' + finding.table + '" and narrow or drop ' +
808
+ 'the loose one. If members need the other columns, a view or function that leaves the secret ones out ' +
809
+ 'is often the better fix.',
810
+ '',
811
+ 'Then check every other table that holds tokens, keys, secrets or payloads for the same thing.',
812
+ ]
767
813
  : finding.kind === 'bucket'
768
814
  ? [
769
815
  'My app may have a storage problem - please check it rather than assume it.',
@@ -958,7 +1004,7 @@ function fixPromptFor(finding) {
958
1004
 
959
1005
  // Found and said, but never claimed as a break: the owner decides. Counted
960
1006
  // apart in the report, never towards the exit code or a clean re-check.
961
- const TO_CHECK = ['privileged', 'role', 'bucket'];
1007
+ const TO_CHECK = ['privileged', 'role', 'teamread', 'bucket'];
962
1008
 
963
1009
  /** Everything the report needs about one thing the attack found. */
964
1010
  function describe(finding) {
@@ -994,6 +1040,8 @@ function describe(finding) {
994
1040
  can: finding.can,
995
1041
  via: finding.via,
996
1042
  roleColumn: finding.roleColumn,
1043
+ readable: finding.readable,
1044
+ secrets: finding.secrets,
997
1045
  bucket: finding.bucket,
998
1046
  proof: finding.kind === 'recursive'
999
1047
  ? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
@@ -1004,8 +1052,11 @@ function describe(finding) {
1004
1052
  ? 'As a "' + finding.who + '" who had just joined, I ' +
1005
1053
  listOf((finding.can || []).map((what) => {
1006
1054
  const n = (finding.changed || {})[what] || 1;
1007
- return (what === 'change' ? 'changed ' : 'deleted ') + n + (n === 1 ? ' row' : ' rows');
1055
+ return ({ add: 'added ', change: 'changed ', delete: 'deleted ' })[what] + n + (n === 1 ? ' row' : ' rows');
1008
1056
  })) + ' in "' + finding.table + '" that I could not touch before joining. All of it was undone.'
1057
+ : finding.kind === 'teamread'
1058
+ ? 'As a "' + finding.who + '" who had just joined, I read ' + (finding.readable || 1) +
1059
+ ((finding.readable || 1) === 1 ? ' row' : ' rows') + ' of "' + finding.table + '" that I could not read before joining.'
1009
1060
  : finding.kind === 'bucket'
1010
1061
  ? 'I read that "' + finding.bucket + '" is public and that ' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
1011
1062
  ' says who may read it. I did not open any file.'
@@ -1113,7 +1164,7 @@ function describeAll(findings) {
1113
1164
  // Writes above reads: a table somebody emptied is worse than one they read.
1114
1165
  // A looping rule sits with the reads: it breaks the app for everyone it
1115
1166
  // fails for, which matters more than a duplicate or a stray row.
1116
- const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, duplicated: 3, orphaned: 4, role: 4.5, bucket: 4.7, privileged: 5 };
1167
+ const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, duplicated: 3, orphaned: 4, role: 4.5, teamread: 4.6, bucket: 4.7, privileged: 5 };
1117
1168
  const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
1118
1169
  return described.sort((a, b) => rank(a) - rank(b));
1119
1170
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon-night",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
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",
package/recheck.js CHANGED
@@ -53,6 +53,53 @@ function proofsOf(list) {
53
53
  return (list || []).reduce((all, item) => all.concat(item.members && item.members.length ? item.members : [item]), []);
54
54
  }
55
55
 
56
+ /*
57
+ * Whether the earlier run was on this database at all.
58
+ *
59
+ * Found on a blind test (HelixOps): the repository's database was scanned,
60
+ * then the production one from the same folder, and the re-check reported the
61
+ * six differences as "NEW problems" and said "A fix can open something else".
62
+ * Nothing had been fixed; it was another database. A run now says where it
63
+ * was; one saved before that is judged by the tables it attacked.
64
+ */
65
+
66
+ // Attacks named for a table, as opposed to a function or a bucket.
67
+ const ON_A_TABLE = /^(exposed|crossed|writable|recursive|orphaned|duplicated|role|teamread):/;
68
+
69
+ /** The tables a run looked at: as it said, or else as its attacks name them. */
70
+ function tablesOf(run, said) {
71
+ if (said) return run.where.tables;
72
+ return Array.from(new Set(((run && run.attempted) || []).filter((key) => ON_A_TABLE.test(String(key)))
73
+ .map((key) => String(key).split(':')[1])));
74
+ }
75
+
76
+ /** A database, said the way a person would recognise it. */
77
+ function placeOf(where) {
78
+ if (!where) return null;
79
+ return '"' + where.database + '"' + (where.host ? ' on ' + where.host + (where.port ? ':' + where.port : '') : '') +
80
+ (where.schema && where.schema !== 'public' ? ', schema "' + where.schema + '"' : '');
81
+ }
82
+
83
+ function elsewhereOf(before, after) {
84
+ const was = before && before.where;
85
+ const now = after && after.where;
86
+ const said = Boolean(was && now && Array.isArray(was.tables) && Array.isArray(now.tables));
87
+ const a = new Set(tablesOf(before, said));
88
+ const b = new Set(tablesOf(after, said));
89
+ const onlyBefore = Array.from(a).filter((t) => !b.has(t)).sort();
90
+ const onlyNow = Array.from(b).filter((t) => !a.has(t)).sort();
91
+ const union = new Set(Array.from(a).concat(Array.from(b))).size;
92
+ const shared = union - onlyBefore.length - onlyNow.length;
93
+ const otherDatabase = Boolean(was && now) && (was.database !== now.database ||
94
+ (was.host || null) !== (now.host || null) || (was.port || null) !== (now.port || null) || was.schema !== now.schema);
95
+ // Fewer than half the tables in common: not the same app any more. Only
96
+ // said when both runs name tables: a run that names none says nothing.
97
+ const otherShape = a.size > 0 && b.size > 0 && shared * 2 < union;
98
+ if (!otherDatabase && !otherShape) return null;
99
+ return { otherDatabase: otherDatabase, otherShape: otherShape, was: placeOf(was), now: placeOf(now),
100
+ onlyBefore: onlyBefore, onlyNow: onlyNow };
101
+ }
102
+
56
103
  function compare(before, after) {
57
104
  const was = proofsOf(before && before.findings);
58
105
  const now = proofsOf(after && after.findings);
@@ -120,9 +167,13 @@ function compare(before, after) {
120
167
 
121
168
  const wasByKey = new Set(was.map(keyOf));
122
169
  const newlyBroken = now.filter((item) => !wasByKey.has(keyOf(item)));
170
+ const elsewhere = elsewhereOf(before, after);
123
171
 
124
172
  return {
125
173
  stopped: null,
174
+ // Set when the earlier run was on another database, or on a very
175
+ // different set of tables: then this is a comparison, not a re-check.
176
+ elsewhere: elsewhere,
126
177
  fixed: fixed,
127
178
  stillOpen: stillOpen,
128
179
  unverifiable: unverifiable,
@@ -138,7 +189,9 @@ function compare(before, after) {
138
189
  // unverifiable, so "the other two are empty" already means "all of them
139
190
  // were fixed" - stating that a third time as a count only made each guard
140
191
  // able to cover for the others, which is how a broken guard stays hidden.
192
+ // And never from two different databases: nothing was fixed between them.
141
193
  allClear:
194
+ !elsewhere &&
142
195
  !stillOpen.length &&
143
196
  !unverifiable.length &&
144
197
  !newlyBroken.length &&
@@ -173,11 +226,58 @@ function whatOf(item) {
173
226
  if (item.kind === 'orphaned') return 'rows left pointing at nothing';
174
227
  if (item.kind === 'privileged') return 'a function anyone can call';
175
228
  if (item.kind === 'role') return 'changed by a "' + (item.who || 'viewer') + '"';
229
+ if (item.kind === 'teamread') return 'read by a "' + (item.who || 'viewer') + '"';
176
230
  if (item.kind === 'bucket') return 'a public storage bucket';
177
231
  if (item.kind === 'recursive') return 'a rule that refers to itself';
178
232
  return item.kind;
179
233
  }
180
234
 
235
+ /** Wraps one paragraph for the screen, indented the way these lines are. */
236
+ function wrapped(text) {
237
+ const out = [];
238
+ let current = '';
239
+ for (const word of String(text).split(/\s+/)) {
240
+ if ((current + ' ' + word).trim().length > 70) {
241
+ out.push(' ' + current.trim());
242
+ current = word;
243
+ } else {
244
+ current = (current + ' ' + word).trim();
245
+ }
246
+ }
247
+ if (current) out.push(' ' + current.trim());
248
+ return out;
249
+ }
250
+
251
+ /** Up to eight names, and how many more. */
252
+ function someOf(names) {
253
+ const shown = names.slice(0, 8).join(', ');
254
+ return names.length > 8 ? shown + ' and ' + (names.length - 8) + ' more' : shown;
255
+ }
256
+
257
+ /**
258
+ * Said first when the earlier run was somewhere else: which two databases,
259
+ * and how their tables differ - so nothing after it reads as a fix.
260
+ */
261
+ function elsewhereLines(elsewhere) {
262
+ const lines = [];
263
+ const tables = (n) => n + (n === 1 ? ' table' : ' tables');
264
+ lines.push(' This is not a re-check of the same database.');
265
+ lines.push('');
266
+ const where = elsewhere.otherDatabase
267
+ ? 'The earlier run kept for this folder was on ' + elsewhere.was + ', and this one is on ' + elsewhere.now + '.'
268
+ : 'The earlier run kept for this folder looked at a very different set of tables.';
269
+ wrapped(where + ' So what follows compares two databases: nothing in it means a fix worked, ' +
270
+ 'and nothing in it means a fix broke something.').forEach((l) => lines.push(l));
271
+ lines.push('');
272
+ const shape = [];
273
+ if (elsewhere.onlyBefore.length) shape.push(tables(elsewhere.onlyBefore.length) + ' only in the earlier one (' + someOf(elsewhere.onlyBefore) + ')');
274
+ if (elsewhere.onlyNow.length) shape.push(tables(elsewhere.onlyNow.length) + ' only in this one (' + someOf(elsewhere.onlyNow) + ')');
275
+ wrapped(shape.length ? 'How their tables differ: ' + shape.join('; ') + '.' : 'Both have the same tables.')
276
+ .forEach((l) => lines.push(l));
277
+ lines.push('');
278
+ return lines;
279
+ }
280
+
181
281
  /** What the re-check says, in the order it matters. */
182
282
  function describe(result) {
183
283
  const lines = [''];
@@ -190,26 +290,34 @@ function describe(result) {
190
290
  return lines;
191
291
  }
192
292
 
293
+ const elsewhere = result.elsewhere;
294
+ if (elsewhere) elsewhereLines(elsewhere).forEach((l) => lines.push(l));
295
+
193
296
  if (result.fixed.length) {
194
- lines.push(' ' + result.fixed.length + (result.fixed.length === 1 ? ' problem is' : ' problems are') + ' fixed:');
297
+ const n = result.fixed.length;
298
+ lines.push(elsewhere
299
+ ? ' ' + n + ' found in the earlier run and not here:'
300
+ : ' ' + n + (n === 1 ? ' problem is' : ' problems are') + ' fixed:');
195
301
  lines.push('');
196
302
  for (const item of result.fixed) {
197
303
  // A function is never called, only read - so its fix is said as what
198
304
  // was seen, not as an attack that was refused. Found on a fix test.
199
305
  const how = item.kind === 'privileged'
200
- ? 'I looked again, and a visitor with no account can no longer call it.'
306
+ ? (elsewhere ? 'I looked here, and a visitor with no account cannot call it.'
307
+ : 'I looked again, and a visitor with no account can no longer call it.')
201
308
  : item.kind === 'bucket'
202
- ? 'I looked again, and the bucket is no longer public.'
309
+ ? (elsewhere ? 'I looked here, and the bucket is not public.' : 'I looked again, and the bucket is no longer public.')
203
310
  : item.kind === 'recursive'
204
- ? 'I read it again, and its rules answered instead of stopping with an error.'
205
- : 'I ran the same attack again and it was refused.';
311
+ ? (elsewhere ? 'I read it here, and its rules answered.'
312
+ : 'I read it again, and its rules answered instead of stopping with an error.')
313
+ : (elsewhere ? 'I ran the same attack here and it was refused.' : 'I ran the same attack again and it was refused.');
206
314
  lines.push(' ' + item.table + ' (' + whatOf(item) + ') - ' + how);
207
315
  }
208
316
  lines.push('');
209
317
  }
210
318
 
211
319
  if (result.stillOpen.length) {
212
- lines.push(' ' + result.stillOpen.length + ' still open:');
320
+ lines.push(' ' + result.stillOpen.length + (elsewhere ? ' found in both:' : ' still open:'));
213
321
  lines.push('');
214
322
  for (const item of result.stillOpen) {
215
323
  lines.push(' ' + item.table + ' - ' + item.headline);
@@ -220,32 +328,44 @@ function describe(result) {
220
328
  // Deliberately not under "fixed", and deliberately not silent. This is the
221
329
  // one a person would otherwise read as good news.
222
330
  if (result.unverifiable.length) {
223
- lines.push(' ' + result.unverifiable.length + ' I could NOT confirm:');
331
+ lines.push(' ' + result.unverifiable.length + (elsewhere ? ' I could NOT compare:' : ' I could NOT confirm:'));
224
332
  lines.push('');
225
333
  for (const item of result.unverifiable) {
226
334
  lines.push(' ' + item.table + ' (' + whatOf(item) + ') - ' + item.why);
227
335
  }
228
336
  lines.push('');
229
- lines.push(' These are not fixed and not broken - they are unknown. The problem');
230
- lines.push(' stopped showing up, but not because the attack was refused.');
337
+ if (elsewhere) {
338
+ lines.push(' These were found in the earlier run, and the same attack did not run');
339
+ lines.push(' here, so whether they are here too is unknown.');
340
+ } else {
341
+ lines.push(' These are not fixed and not broken - they are unknown. The problem');
342
+ lines.push(' stopped showing up, but not because the attack was refused.');
343
+ }
231
344
  lines.push('');
232
345
  }
233
346
 
234
347
  if (result.newlyBroken.length) {
235
- lines.push(' ' + result.newlyBroken.length + ' NEW ' +
236
- (result.newlyBroken.length === 1 ? 'problem' : 'problems') + ' that were not there before:');
348
+ const n = result.newlyBroken.length;
349
+ lines.push(elsewhere
350
+ ? ' ' + n + ' found here and not in the earlier run:'
351
+ : ' ' + n + ' NEW ' + (n === 1 ? 'problem' : 'problems') + ' that were not there before:');
237
352
  lines.push('');
238
353
  for (const item of result.newlyBroken) {
239
354
  lines.push(' ' + item.table + ' - ' + item.headline);
240
355
  }
241
356
  lines.push('');
242
- lines.push(' A fix can open something else. This is why the re-check looks at the');
243
- lines.push(' whole app again and not only at what it was asked about.');
357
+ if (elsewhere) {
358
+ lines.push(' These are differences between the two databases, not something a fix');
359
+ lines.push(' opened.');
360
+ } else {
361
+ lines.push(' A fix can open something else. This is why the re-check looks at the');
362
+ lines.push(' whole app again and not only at what it was asked about.');
363
+ }
244
364
  lines.push('');
245
365
  }
246
366
 
247
367
  if (!result.fixed.length && !result.stillOpen.length && !result.unverifiable.length && !result.newlyBroken.length) {
248
- lines.push(' Nothing to re-check - there was nothing open.');
368
+ lines.push(elsewhere ? ' Neither run found anything open.' : ' Nothing to re-check - there was nothing open.');
249
369
  lines.push('');
250
370
  }
251
371
 
@@ -266,6 +386,7 @@ function describe(result) {
266
386
 
267
387
  module.exports = {
268
388
  keyOf: keyOf,
389
+ elsewhereOf: elsewhereOf,
269
390
  compare: compare,
270
391
  describe: describe,
271
392
  badgeLines: badgeLines,
package/scan.js CHANGED
@@ -416,8 +416,21 @@ async function scan(client, sourceSchema, options) {
416
416
  // copy, the way seeding did.
417
417
  const tagged = taggerFor(plan, theirs);
418
418
 
419
+ // Where this run was, so a re-check can tell a database it saw before from
420
+ // another one entirely (recheck.elsewhereOf). The name and address only.
421
+ const at = (await client.query(
422
+ 'SELECT current_database() AS database, host(inet_server_addr()) AS host, inet_server_port() AS port',
423
+ )).rows[0];
424
+
419
425
  return {
420
426
  stopped: null,
427
+ where: {
428
+ database: at.database,
429
+ host: at.host || null,
430
+ port: at.port || null,
431
+ schema: sourceSchema,
432
+ tables: plan.tables.map((table) => table.name).sort(),
433
+ },
421
434
  attacksRun: attempted.length,
422
435
  notChecked: notChecked,
423
436
  attempted: attempted,
@@ -786,8 +799,8 @@ function report(result) {
786
799
  if (toVerify.length) {
787
800
  // A viewer who can edit was really tried; a function or a bucket was only
788
801
  // read. Either way it is the owner's call, and the line says which.
789
- const tried = toVerify.some((f) => f.kind === 'role');
790
- const spotted = toVerify.some((f) => f.kind !== 'role');
802
+ const tried = toVerify.some((f) => f.kind === 'role' || f.kind === 'teamread');
803
+ const spotted = toVerify.some((f) => f.kind !== 'role' && f.kind !== 'teamread');
791
804
  const it = toVerify.length === 1 ? 'it' : 'them';
792
805
  line(' ' + toVerify.length + ' thing' + (toVerify.length === 1 ? '' : 's') + ' to check - ' +
793
806
  (spotted && !tried ? 'I did not attack ' + it + ', only spotted the risk.'
@@ -912,7 +925,9 @@ async function main() {
912
925
  if (result.findings.length) report(result);
913
926
  else notTestedLines(result).forEach(line);
914
927
  saveRun(file, result);
915
- process.exitCode = result.stopped ? 2 : verdict.allClear ? 0 : 1;
928
+ // Two different databases: the verdict is a comparison, so the exit code
929
+ // is this run's own answer, the same as a first run.
930
+ process.exitCode = result.stopped ? 2 : verdict.elsewhere ? exitCodeFor(result) : verdict.allClear ? 0 : 1;
916
931
  } finally {
917
932
  await client.end();
918
933
  }
package/tamper.js CHANGED
@@ -244,6 +244,24 @@ const LEAST_TRUSTED = [/view/i, /read/i, /guest/i, /observ/i, /audit/i];
244
244
  const IN_CHARGE = /owner|admin|super|root/i;
245
245
  // Owner columns that name who made a row rather than who is in it.
246
246
  const CREATOR = /^(created_by|author_id|owner|owner_id)$/i;
247
+ // Columns that hold something a member should not simply be able to read:
248
+ // a token, a secret or a key, a password, or the payload a webhook carried.
249
+ // Found on a blind test (HelixOps): integration_tokens.token_value and
250
+ // webhook_deliveries.payload readable by every member, and api_clients
251
+ // (client_key, secret_hash) by a dispatcher. A word on its own between
252
+ // underscores, so tokens - a count - and question_key are not taken.
253
+ // AtlasPay's webhook deliveries kept theirs as request_body and response_body.
254
+ const SECRET_COLUMN = /(^|_)(token|secret|password|passwd|pwd|payload|credentials?|(api|private|access|client|signing|service|encryption|secret|license)_?key|(request|response)_body)(_|$)/i;
255
+
256
+ /** The columns of a table that look like they hold a secret. */
257
+ function secretColumns(table) {
258
+ return (table.columns || []).map((c) => c.name).filter((name) => SECRET_COLUMN.test(name));
259
+ }
260
+
261
+ /** Whether a role's own name says it may only look. */
262
+ function lookOnly(role) {
263
+ return LEAST_TRUSTED.some((pattern) => pattern.test(role));
264
+ }
247
265
 
248
266
  /** The role a viewer has, out of a team's list of roles, or null. */
249
267
  function lowestRole(labels) {
@@ -267,7 +285,11 @@ function roleColumnOf(table) {
267
285
  : attack.allowedByCheck(table, column.name);
268
286
  if (labels.length < 2) continue;
269
287
  const lowest = lowestRole(labels);
270
- if (lowest) return { column: column.name, lowest: lowest };
288
+ // Every role not plainly in charge, the lowest first and then upwards:
289
+ // the order reads are tried in, so the least trusted one that can read a
290
+ // secret is the one named.
291
+ const ladder = [lowest].concat(labels.filter((label) => label !== lowest && !IN_CHARGE.test(label)).reverse());
292
+ if (lowest) return { column: column.name, lowest: lowest, ladder: ladder };
271
293
  }
272
294
  return null;
273
295
  }
@@ -303,7 +325,7 @@ function memberships(tables) {
303
325
  const keys = attack.foreignKeys(table).concat(attack.impliedKeys(table, tables))
304
326
  .filter((key) => key.refTable !== table.name && names.has(key.refTable) && !key.columns.includes(person));
305
327
  if (!keys.length || aloneUnique(table, person)) continue;
306
- found.push({ table: table, person: person, role: role.column, lowest: role.lowest, keys: keys });
328
+ found.push({ table: table, person: person, role: role.column, lowest: role.lowest, ladder: role.ladder, keys: keys });
307
329
  }
308
330
  // A table named for members first: it is the one a team rule asks.
309
331
  const named = (m) => (/member/i.test(m.table.name) ? 0 : 1);
@@ -325,6 +347,11 @@ async function teammate(client, schema, tables, seeded) {
325
347
  const blocked = [];
326
348
  const shapeFor = new Map((seeded || []).map((entry) => [entry.table, entry.attempt || 0]));
327
349
  const done = new Set();
350
+ // What a write or a read achieved, or null when it decided nothing.
351
+ const counted = (result) => {
352
+ const outcome = whatHappened(result);
353
+ return outcome === 'untested' ? null : outcome === 'got through' ? result.count : 0;
354
+ };
328
355
 
329
356
  for (const member of memberships(tables)) {
330
357
  if (!shapeFor.has(member.table.name)) continue;
@@ -339,22 +366,32 @@ async function teammate(client, schema, tables, seeded) {
339
366
  if (!theirs) continue;
340
367
 
341
368
  // Their team, as the first person's own membership names it.
342
- const overrides = {};
343
- overrides[member.role] = member.lowest;
369
+ const team = {};
344
370
  for (const key of member.keys) {
345
- key.columns.forEach((name) => { if (name !== member.person) overrides[name] = theirs[name]; });
371
+ key.columns.forEach((name) => { if (name !== member.person) team[name] = theirs[name]; });
346
372
  }
347
- const parent = member.keys[0].refTable;
348
- const as = 'as a "' + member.lowest + '" in ' + member.table.name;
349
- let join = null;
350
- let refused = null;
351
- try {
373
+ const parentKey = member.keys[0];
374
+ const parent = parentKey.refTable;
375
+ // Putting the other person in that team, as one role. Built once a role.
376
+ const joins = new Map();
377
+ const joinAs = async (role) => {
378
+ if (joins.has(role)) return joins.get(role);
379
+ const overrides = Object.assign({}, team);
380
+ overrides[member.role] = role;
352
381
  const row = await attack.rowFor(client, schema, member.table, attack.USER_B, 9, overrides, shapeFor.get(member.table.name));
353
- join = {
382
+ const join = {
354
383
  statement: 'INSERT INTO ' + at + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' +
355
384
  row.values.map((_, i) => '$' + (i + 1)).join(', ') + ')',
356
385
  values: row.values,
357
386
  };
387
+ joins.set(role, join);
388
+ return join;
389
+ };
390
+ const as = (role) => 'as a "' + role + '" in ' + member.table.name;
391
+ let join = null;
392
+ let refused = null;
393
+ try {
394
+ join = await joinAs(member.lowest);
358
395
  // Whether they can be put in the team at all, tried once on its own.
359
396
  await client.query('BEGIN');
360
397
  try {
@@ -369,7 +406,7 @@ async function teammate(client, schema, tables, seeded) {
369
406
  // Said once, on the table that would not take them: every team table
370
407
  // went untested for the same reason.
371
408
  blocked.push({ table: member.table.name, key: 'role:' + member.table.name + ':' + member.lowest,
372
- why: as + ': I could not add one: ' + refused });
409
+ why: as(member.lowest) + ': I could not add one: ' + refused });
373
410
  continue;
374
411
  }
375
412
 
@@ -378,25 +415,60 @@ async function teammate(client, schema, tables, seeded) {
378
415
  done.add(table.name);
379
416
  const key = 'role:' + table.name + ':' + member.lowest;
380
417
  const owner = attack.ownerColumn(table);
418
+ // A table that names the team itself, and the team it is to name.
419
+ const toTeam = attack.foreignKeys(table).concat(attack.impliedKeys(table, tables))
420
+ .find((k) => k.refTable === parent && k.columns.length === parentKey.refColumns.length);
421
+ const pointed = {};
422
+ if (toTeam) {
423
+ toTeam.columns.forEach((name, i) => {
424
+ pointed[name] = team[parentKey.columns[parentKey.refColumns.indexOf(toTeam.refColumns[i])]];
425
+ });
426
+ }
381
427
  // Never their own rows: changing what you made yourself is not a role
382
- // question, and the row that put them in the team is theirs.
383
- const notTheirs = owner ? ' WHERE ' + quote(owner) + " IS DISTINCT FROM '" + attack.USER_B + "'" : '';
428
+ // question, and the row that put them in the team is theirs. And only
429
+ // the team they joined, where the table says which team a row is in:
430
+ // their own team's rows, which they own, were refused by a foreign key
431
+ // on the way out and left the table untested.
432
+ const where = [];
433
+ if (owner) where.push(quote(owner) + " IS DISTINCT FROM '" + attack.USER_B + "'");
434
+ for (const name of Object.keys(pointed)) {
435
+ where.push(quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'");
436
+ }
437
+ const notTheirs = where.length ? ' WHERE ' + where.join(' AND ') : '';
384
438
  const target = quote(schema) + '.' + quote(table.name);
385
439
  const column = quote(firstWritable(table));
386
440
  const moves = [
387
- { what: 'change', statement: 'UPDATE ' + target + ' SET ' + column + ' = ' + column + notTheirs },
388
- { what: 'delete', statement: 'DELETE FROM ' + target + notTheirs },
441
+ { what: 'change', statement: 'UPDATE ' + target + ' SET ' + column + ' = ' + column + notTheirs, values: [] },
442
+ { what: 'delete', statement: 'DELETE FROM ' + target + notTheirs, values: [] },
389
443
  ];
444
+ // Adding a row to the team, said only of a role whose own name says it
445
+ // may only look: a member adding rows is the feature, a viewer adding
446
+ // them is not. Found on a blind test (HelixOps): any member could add
447
+ // alert subscriptions. Only a table that names the team itself. Written
448
+ // under the other person's name: a rule that lets a member add a row
449
+ // only as themselves - a note they wrote - is a member speaking for
450
+ // themselves, and refuses it (OrbitDesk's internal notes, by design).
451
+ if (lookOnly(member.lowest) && toTeam) {
452
+ try {
453
+ const row = await attack.rowFor(client, schema, table, attack.USER_A, 11, pointed, shapeFor.get(table.name));
454
+ moves.unshift({
455
+ what: 'add',
456
+ statement: row.columns.length
457
+ ? 'INSERT INTO ' + target + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' +
458
+ row.values.map((_, i) => '$' + (i + 1)).join(', ') + ')'
459
+ : 'INSERT INTO ' + target + ' DEFAULT VALUES',
460
+ values: row.values,
461
+ });
462
+ } catch (err) {
463
+ // Nothing to point a new row at: there is no row to add.
464
+ }
465
+ }
390
466
  const can = [];
391
467
  const changed = {};
392
468
  let stuck = null;
393
469
  for (const move of moves) {
394
- const before = await tryWrite(client, 'authenticated', attack.USER_B, move.statement, []);
395
- const after = await tryWrite(client, 'authenticated', attack.USER_B, move.statement, [], join);
396
- const counted = (result) => {
397
- const outcome = whatHappened(result);
398
- return outcome === 'untested' ? null : outcome === 'got through' ? result.count : 0;
399
- };
470
+ const before = await tryWrite(client, 'authenticated', attack.USER_B, move.statement, move.values);
471
+ const after = await tryWrite(client, 'authenticated', attack.USER_B, move.statement, move.values, join);
400
472
  const was = counted(before);
401
473
  const now = counted(after);
402
474
  if (was === null || now === null) {
@@ -407,23 +479,65 @@ async function teammate(client, schema, tables, seeded) {
407
479
  }
408
480
  }
409
481
  if (!can.length && stuck) {
410
- blocked.push({ table: table.name, key: key, why: as + ': ' + stuck });
411
- continue;
482
+ blocked.push({ table: table.name, key: key, why: as(member.lowest) + ': ' + stuck });
483
+ } else {
484
+ completed.push(key);
485
+ if (can.length) {
486
+ findings.push({
487
+ kind: 'role',
488
+ table: table.name,
489
+ who: member.lowest,
490
+ via: member.table.name,
491
+ roleColumn: member.role,
492
+ parent: parent,
493
+ can: can,
494
+ changed: changed,
495
+ columns: (table.columns || []).map((c) => c.name),
496
+ rlsEnabled: table.rlsEnabled,
497
+ });
498
+ }
412
499
  }
413
- completed.push(key);
414
- if (can.length) {
415
- findings.push({
416
- kind: 'role',
417
- table: table.name,
418
- who: member.lowest,
419
- via: member.table.name,
420
- roleColumn: member.role,
421
- parent: parent,
422
- can: can,
423
- changed: changed,
424
- columns: (table.columns || []).map((c) => c.name),
425
- rlsEnabled: table.rlsEnabled,
426
- });
500
+
501
+ // Reading what a member should not see, said only of a table holding a
502
+ // secret. Every role not in charge, from the lowest up; the first that
503
+ // can read is the one named. A member reading their team's ordinary
504
+ // rows is the feature.
505
+ const secrets = secretColumns(table);
506
+ if (!secrets.length) continue;
507
+ const look = 'SELECT 1 FROM ' + target + notTheirs;
508
+ const before = counted(await tryWrite(client, 'authenticated', attack.USER_B, look, []));
509
+ const tried = [];
510
+ for (const role of member.ladder) {
511
+ const readKey = 'teamread:' + table.name + ':' + role;
512
+ let after;
513
+ try {
514
+ after = await tryWrite(client, 'authenticated', attack.USER_B, look, [], await joinAs(role));
515
+ } catch (err) {
516
+ after = { ok: false, count: 0, why: err.message };
517
+ }
518
+ const now = counted(after);
519
+ if (before === null || now === null) {
520
+ blocked.push({ table: table.name, key: readKey, why: as(role) + ': ' + (now === null ? after.why : 'reading it failed before joining') });
521
+ break;
522
+ }
523
+ completed.push(readKey);
524
+ if (now > before) {
525
+ findings.push({
526
+ kind: 'teamread',
527
+ table: table.name,
528
+ who: role,
529
+ via: member.table.name,
530
+ roleColumn: member.role,
531
+ parent: parent,
532
+ readable: now - before,
533
+ secrets: secrets,
534
+ below: tried.slice(),
535
+ columns: (table.columns || []).map((c) => c.name),
536
+ rlsEnabled: table.rlsEnabled,
537
+ });
538
+ break;
539
+ }
540
+ tried.push(role);
427
541
  }
428
542
  }
429
543
  }
@@ -438,6 +552,8 @@ module.exports = {
438
552
  writeAsFor: writeAsFor,
439
553
  tamper: tamper,
440
554
  lowestRole: lowestRole,
555
+ secretColumns: secretColumns,
556
+ lookOnly: lookOnly,
441
557
  roleColumnOf: roleColumnOf,
442
558
  memberships: memberships,
443
559
  teammate: teammate,