kryptheon-night 0.1.27 → 0.1.29

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 or add to what is in it, and which roles can read its tokens, keys and payloads? (tried on the copy, undone) |
77
+ | **Team roles** | which role in a team, from the lowest up, can change what its rules do not list it for, and which 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/collision.js CHANGED
@@ -69,6 +69,20 @@ const MUST_BE_UNIQUE = [
69
69
  */
70
70
  const ACCOUNT_TABLE = /^(users?|profiles?|accounts?|members?|auth_users|app_users|logins?)$/i;
71
71
 
72
+ /**
73
+ * Tables that keep another service's credentials, so this app can call it.
74
+ *
75
+ * A token there was issued by somebody else and opens nothing in this app, so
76
+ * two rows sharing one is not a way in. Found on a blind test (LaunchRail):
77
+ * integration_credentials.access_token and refresh_token were reported as
78
+ * "can exist twice" - not in the answer key, and not a door into the app.
79
+ * Known by the table's name, or by a `provider` column naming the service.
80
+ */
81
+ const OUTBOUND_TABLE = /(^|_)(integrations?|providers?|oauth|connections?|connectors?|external|third_party)(_|$)/i;
82
+ function holdsOutboundCredentials(table) {
83
+ return OUTBOUND_TABLE.test(table.name) || (table.columns || []).some((c) => /^provider$/i.test(c.name));
84
+ }
85
+
72
86
  /** Everything on a table that already promises "only one of these". */
73
87
  function uniquenessTexts(table, indexes) {
74
88
  const fromConstraints = (table.constraints || [])
@@ -118,6 +132,7 @@ function candidates(tables, indexes) {
118
132
  const rule = MUST_BE_UNIQUE.find((r) => r.test.test(column.name));
119
133
  if (!rule) continue;
120
134
  if (rule.accountTablesOnly && !ACCOUNT_TABLE.test(table.name)) continue;
135
+ if (rule.expectation === 'credential' && holdsOutboundCredentials(table)) continue;
121
136
  found.push({
122
137
  table: table.name,
123
138
  column: column.name,
package/engine.sql CHANGED
@@ -2493,7 +2493,7 @@ BEGIN
2493
2493
  IF lowest IS NOT NULL THEN
2494
2494
  -- Every role not plainly in charge, the lowest first and then upwards:
2495
2495
  -- the order reads are tried in.
2496
- RETURN jsonb_build_object('column', col->>'name', 'lowest', lowest, 'ladder',
2496
+ RETURN jsonb_build_object('column', col->>'name', 'type', col->>'type', 'lowest', lowest, 'ladder',
2497
2497
  jsonb_build_array(lowest) || coalesce((
2498
2498
  SELECT jsonb_agg(l ORDER BY pos DESC)
2499
2499
  FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, pos)
@@ -2563,7 +2563,7 @@ BEGIN
2563
2563
  CONTINUE WHEN jsonb_array_length(keys) = 0 OR __KN__.alone_unique(tab, person);
2564
2564
  found := found || jsonb_build_array(jsonb_build_object(
2565
2565
  'table', tab, 'person', person, 'role', role_->>'column', 'lowest', role_->>'lowest',
2566
- 'ladder', role_->'ladder', 'keys', keys));
2566
+ 'roleType', role_->>'type', 'ladder', role_->'ladder', 'keys', keys));
2567
2567
  END LOOP;
2568
2568
  -- A table named for members first: it is the one a team rule asks.
2569
2569
  SELECT coalesce(jsonb_agg(m ORDER BY (m->'table'->>'name') !~* 'member', at), '[]'::jsonb) INTO found
@@ -2623,7 +2623,49 @@ RETURNS bigint LANGUAGE sql IMMUTABLE AS $$
2623
2623
  ELSE 0 END;
2624
2624
  $$;
2625
2625
 
2626
- CREATE OR REPLACE FUNCTION __KN__.teammate(source text, tables jsonb, seeded jsonb)
2626
+ /*
2627
+ * Whether the app's own rules name this role among those allowed to make
2628
+ * this kind of write: the role in a list in a permissive rule for that
2629
+ * command, or in a list inside a helper that rule calls. A rule written for
2630
+ * one role on its own (role = 'analyst', LaunchRail) is not a list of who may
2631
+ * write. Mirrors allowedByRules in tamper.js.
2632
+ */
2633
+ CREATE OR REPLACE FUNCTION __KN__.allowed_by_rules(rules jsonb, table_name text, what text, role_ text, type_ text)
2634
+ RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
2635
+ DECLARE
2636
+ esc constant text := '([.*+?^${}()|\[\]\\])';
2637
+ label text := '''' || regexp_replace(replace(role_, '''', ''''''), esc, '\\\1', 'g') || '''';
2638
+ type_name text := regexp_replace(replace(split_part(coalesce(type_, 'text'), '.', -1), '"', ''), esc, '\\\1', 'g');
2639
+ in_rule_list text := 'ARRAY\[[^]]*' || label || '::("?[A-Za-z0-9_$]+"?\.)?"?' || type_name || '"?';
2640
+ -- The same list written as an array literal, kept as '{...}'::role[].
2641
+ in_rule_literal text := '''\{([^}'']*,)?"?' || regexp_replace(role_, esc, '\\\1', 'g') ||
2642
+ '"?(,[^}'']*)?\}''::("?[A-Za-z0-9_$]+"?\.)?"?' || type_name || '"?\[\]';
2643
+ in_helper_list text := '(\mIN\s*\(|ARRAY\[)[^])]*' || label;
2644
+ command text := CASE what WHEN 'add' THEN 'INSERT' WHEN 'change' THEN 'UPDATE' WHEN 'delete' THEN 'DELETE' END;
2645
+ policy jsonb;
2646
+ body text;
2647
+ BEGIN
2648
+ FOR policy IN SELECT * FROM jsonb_array_elements(coalesce(rules->'policies', '[]'::jsonb)) LOOP
2649
+ CONTINUE WHEN policy->>'table_name' <> table_name;
2650
+ CONTINUE WHEN upper(coalesce(policy->>'permissive', 'PERMISSIVE')) <> 'PERMISSIVE';
2651
+ CONTINUE WHEN upper(coalesce(policy->>'cmd', 'ALL')) NOT IN ('ALL', command);
2652
+ body := coalesce(policy->>'qual', '') || ' ' || coalesce(policy->>'with_check', '');
2653
+ IF body ~* in_rule_list OR body ~* in_rule_literal THEN RETURN true; END IF;
2654
+ IF EXISTS (
2655
+ SELECT 1 FROM jsonb_array_elements(coalesce(rules->'functions', '[]'::jsonb)) f
2656
+ WHERE __KN__.calls_function(body, f->>'name')
2657
+ AND coalesce(f->>'src', '') ~* in_helper_list) THEN
2658
+ RETURN true;
2659
+ END IF;
2660
+ END LOOP;
2661
+ RETURN false;
2662
+ END $$;
2663
+
2664
+ -- The three-argument teammate of 0.1.26-0.1.28, dropped so an engine
2665
+ -- upgraded in place keeps one version and every call stays unambiguous.
2666
+ DROP FUNCTION IF EXISTS __KN__.teammate(text, jsonb, jsonb);
2667
+
2668
+ CREATE OR REPLACE FUNCTION __KN__.teammate(source text, tables jsonb, seeded jsonb, rules jsonb)
2627
2669
  RETURNS jsonb LANGUAGE plpgsql AS $$
2628
2670
  DECLARE
2629
2671
  findings jsonb := '[]'::jsonb;
@@ -2668,6 +2710,9 @@ DECLARE
2668
2710
  role_ text;
2669
2711
  tried jsonb;
2670
2712
  i integer;
2713
+ before_all jsonb;
2714
+ below jsonb;
2715
+ listed boolean;
2671
2716
  BEGIN
2672
2717
  FOR member IN SELECT * FROM jsonb_array_elements(__KN__.memberships(tables)) LOOP
2673
2718
  SELECT (s->>'attempt')::integer INTO shape
@@ -2743,7 +2788,6 @@ BEGIN
2743
2788
  FROM jsonb_array_elements(coalesce(seeded, '[]'::jsonb)) s
2744
2789
  WHERE s->>'table' = tab->>'name';
2745
2790
  shape := coalesce(shape, 0);
2746
- finding_key := 'role:' || (tab->>'name') || ':' || (member->>'lowest');
2747
2791
  owner := __KN__.owner_column(tab);
2748
2792
 
2749
2793
  -- A table that names the team itself, and the team it is to name.
@@ -2786,52 +2830,84 @@ BEGIN
2786
2830
  -- may only look, and written under the other person's name: a rule that
2787
2831
  -- lets a member add a row only as themselves is them speaking for
2788
2832
  -- themselves.
2789
- IF __KN__.look_only(member->>'lowest') AND to_team IS NOT NULL THEN
2833
+ IF to_team IS NOT NULL AND EXISTS (
2834
+ SELECT 1 FROM jsonb_array_elements_text(member->'ladder') r WHERE __KN__.look_only(r)) THEN
2790
2835
  BEGIN
2791
2836
  row_ := __KN__.row_for(source, tab, __KN__.user_a(), '11', pointed, shape);
2792
- moves := jsonb_build_array(jsonb_build_object('what', 'add',
2837
+ moves := jsonb_build_array(jsonb_build_object('what', 'add', 'lookOnly', true,
2793
2838
  'statement', __KN__.insert_statement(source, tab->>'name', row_))) || moves;
2794
2839
  EXCEPTION WHEN OTHERS THEN
2795
2840
  NULL;
2796
2841
  END;
2797
2842
  END IF;
2798
2843
 
2799
- can := '[]'::jsonb;
2800
- changed := '{}'::jsonb;
2801
- stuck := NULL;
2844
+ -- Every role not in charge, from the lowest up, the way reads are tried
2845
+ -- (LaunchRail: production gave "analyst" its own UPDATE rule and only
2846
+ -- "viewer" was tried). The first role that can write where the app's own
2847
+ -- rules do not list it is the one named. What each write did before
2848
+ -- joining is asked once.
2849
+ before_all := '{}'::jsonb;
2802
2850
  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;
2851
+ before_all := jsonb_set(before_all, ARRAY[move->>'what'],
2852
+ __KN__.try_write('authenticated', __KN__.user_b(), move->>'statement'));
2813
2853
  END LOOP;
2854
+ below := '[]'::jsonb;
2855
+ FOR role_ IN SELECT jsonb_array_elements_text(member->'ladder') LOOP
2856
+ finding_key := 'role:' || (tab->>'name') || ':' || role_;
2857
+ IF NOT (joins ? role_) THEN
2858
+ blocked := blocked || jsonb_build_array(jsonb_build_object(
2859
+ 'table', tab->>'name', 'key', finding_key,
2860
+ 'why', 'as a "' || role_ || '" in ' || (member->'table'->>'name') || ': I could not add one'));
2861
+ EXIT;
2862
+ END IF;
2863
+ can := '[]'::jsonb;
2864
+ changed := '{}'::jsonb;
2865
+ stuck := NULL;
2866
+ listed := false;
2867
+ FOR move IN SELECT * FROM jsonb_array_elements(moves) LOOP
2868
+ CONTINUE WHEN coalesce((move->>'lookOnly')::boolean, false) AND NOT __KN__.look_only(role_);
2869
+ -- A write the rules list this role for is not asked: whatever it did
2870
+ -- would not be reported, and a key refusing it left the table untested.
2871
+ IF __KN__.allowed_by_rules(rules, tab->>'name', move->>'what', role_, member->>'roleType') THEN
2872
+ listed := true;
2873
+ CONTINUE;
2874
+ END IF;
2875
+ before_ := before_all->(move->>'what');
2876
+ after_ := __KN__.try_write_joined('authenticated', __KN__.user_b(), move->>'statement', joins->>role_);
2877
+ was := __KN__.rows_moved(before_);
2878
+ now_ := __KN__.rows_moved(after_);
2879
+ IF was IS NULL OR now_ IS NULL THEN
2880
+ stuck := CASE WHEN now_ IS NULL THEN after_->>'why' ELSE before_->>'why' END;
2881
+ ELSIF now_ > was THEN
2882
+ can := can || to_jsonb(move->>'what');
2883
+ changed := jsonb_set(changed, ARRAY[move->>'what'], to_jsonb(now_ - was));
2884
+ END IF;
2885
+ END LOOP;
2814
2886
 
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
2887
+ IF jsonb_array_length(can) = 0 AND stuck IS NOT NULL THEN
2888
+ blocked := blocked || jsonb_build_array(jsonb_build_object(
2889
+ 'table', tab->>'name', 'key', finding_key,
2890
+ 'why', 'as a "' || role_ || '" in ' || (member->'table'->>'name') || ': ' || stuck));
2891
+ EXIT;
2892
+ END IF;
2820
2893
  completed := completed || to_jsonb(finding_key);
2821
2894
  IF jsonb_array_length(can) > 0 THEN
2822
2895
  findings := findings || jsonb_build_array(jsonb_build_object(
2823
2896
  'kind', 'role',
2824
2897
  'table', tab->>'name',
2825
- 'who', member->>'lowest',
2898
+ 'who', role_,
2826
2899
  'via', member->'table'->>'name',
2827
2900
  'roleColumn', member->>'role',
2828
2901
  'parent', parent,
2829
2902
  'can', can,
2830
2903
  'changed', changed,
2904
+ 'below', below,
2831
2905
  'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c), '[]'::jsonb),
2832
2906
  'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
2907
+ EXIT;
2833
2908
  END IF;
2834
- END IF;
2909
+ IF NOT listed THEN below := below || to_jsonb(role_); END IF;
2910
+ END LOOP;
2835
2911
 
2836
2912
  -- Reading what a member should not see, said only of a table holding a
2837
2913
  -- secret. Every role not in charge, from the lowest up; the first that
@@ -3222,11 +3298,20 @@ DECLARE
3222
3298
  tab jsonb;
3223
3299
  col jsonb;
3224
3300
  expectation text;
3301
+ outbound boolean;
3225
3302
  BEGIN
3226
3303
  FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
3304
+ -- A table keeping another service's credentials, so this app can call
3305
+ -- it: a token there opens nothing in this app, and two rows sharing one
3306
+ -- is not a way in. Found on a blind test (LaunchRail), where
3307
+ -- integration_credentials.access_token was reported as "can exist twice".
3308
+ outbound := tab->>'name' ~* '(^|_)(integrations?|providers?|oauth|connections?|connectors?|external|third_party)(_|$)'
3309
+ OR EXISTS (SELECT 1 FROM jsonb_array_elements(coalesce(tab->'columns', '[]'::jsonb)) c
3310
+ WHERE c->>'name' ~* '^provider$');
3227
3311
  FOR col IN SELECT * FROM jsonb_array_elements(coalesce(tab->'columns', '[]'::jsonb)) LOOP
3228
3312
  expectation := __KN__.must_be_unique(col->>'name', tab->>'name');
3229
3313
  CONTINUE WHEN expectation IS NULL;
3314
+ CONTINUE WHEN expectation = 'credential' AND outbound;
3230
3315
  found := found || jsonb_build_array(jsonb_build_object(
3231
3316
  'table', tab->>'name',
3232
3317
  'column', col->>'name',
@@ -3394,7 +3479,8 @@ BEGIN
3394
3479
 
3395
3480
  seen := __KN__.impersonate(copy_name, theirs || views);
3396
3481
  wrote := __KN__.tamper(copy_name, theirs, sown->'seeded', copy_plan->'policies');
3397
- ranked := __KN__.teammate(copy_name, theirs, sown->'seeded');
3482
+ ranked := __KN__.teammate(copy_name, theirs, sown->'seeded',
3483
+ jsonb_build_object('policies', copy_plan->'policies', 'functions', copy_plan->'functions'));
3398
3484
  stranded := __KN__.orphan(copy_name, theirs, sown->'seeded');
3399
3485
  raced := __KN__.collide(copy_name, theirs, copy_plan->'indexes');
3400
3486
 
package/finding.js CHANGED
@@ -89,6 +89,16 @@ function listOf(items) {
89
89
  }
90
90
 
91
91
  /** One of a table's things: workspaces -> workspace, companies -> company. */
92
+ /** A role, quoted, with its article: a "viewer", an "analyst". */
93
+ function aRole(role) {
94
+ return (/^[aeiou]/i.test(String(role)) ? 'an "' : 'a "') + role + '"';
95
+ }
96
+
97
+ /** The first letter made a capital, for the start of a sentence. */
98
+ function capital(text) {
99
+ return text.charAt(0).toUpperCase() + text.slice(1);
100
+ }
101
+
92
102
  /** The same, with its article: a workspace, an organization. */
93
103
  function aOne(table) {
94
104
  const one = oneOf(table);
@@ -351,11 +361,11 @@ function headlineFor(finding) {
351
361
  if (finding.kind === 'role') {
352
362
  const order = ['delete', 'change', 'add'];
353
363
  const does = order.filter((what) => (finding.can || []).includes(what)).map((what) => WRITE_WORDS[what]);
354
- return 'A "' + finding.who + '" in ' + aOne(finding.parent) + ' can ' + listOf(does) + ' your ' +
364
+ return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can ' + listOf(does) + ' your ' +
355
365
  finding.table + ' table.';
356
366
  }
357
367
  if (finding.kind === 'teamread') {
358
- return 'A "' + finding.who + '" in ' + aOne(finding.parent) + ' can read your ' + finding.table +
368
+ return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can read your ' + finding.table +
359
369
  ' table, which holds ' + listOf(finding.secrets || []) + '.';
360
370
  }
361
371
  if (finding.kind === 'bucket') {
@@ -453,13 +463,18 @@ function bodyFor(finding, contents) {
453
463
  if ((finding.can || []).includes('change')) did.push('changed ' + rows((finding.changed || {}).change || 1));
454
464
  if ((finding.can || []).includes('delete')) did.push('deleted ' + rows((finding.changed || {}).delete || 1));
455
465
  const place = oneOf(finding.parent);
466
+ // The roles below it that were tried first and could not, when there are
467
+ // any: a role in the middle is named because the ones under it held.
468
+ const below = (finding.below || []).map((r) => '"' + r + '"');
456
469
  return (
457
- 'I put one of my fake people into the other one\'s ' + place + ' as a "' + finding.who +
458
- '" - the lowest role in ' + finding.via + '.' + finding.roleColumn + ' - and, signed in as them, I ' +
459
- listOf(did) + ' in ' + finding.table + ' for that ' + place + ', on a copy of your app. ' +
460
- 'Before joining, they could not. Every one of those was undone straight away. Whether a "' + finding.who +
461
- '" may do this is your decision: if it is meant to, nothing needs fixing; if a "' + finding.who +
462
- '" should only look, the rules on this table let them do more.'
470
+ 'I put one of my fake people into the other one\'s ' + place + ' as ' + aRole(finding.who) +
471
+ ' - ' + (below.length ? 'a role' : 'the lowest role') + ' in ' + finding.via + '.' + finding.roleColumn +
472
+ ' - and, signed in as them, I ' + listOf(did) + ' in ' + finding.table + ' for that ' + place +
473
+ ', on a copy of your app. Before joining, they could not.' +
474
+ (below.length ? ' As ' + listOf(below).replace(/ and /, ' or ') + ' they could not either, and no rule on this ' +
475
+ 'table lists ' + aRole(finding.who).replace(/^an? /, '') + ' among the roles allowed to make that change.' : '') +
476
+ ' Every one of those was undone straight away. Whether ' + aRole(finding.who) + ' may do this is your ' +
477
+ 'decision: if it is meant to, nothing needs fixing; if not, the rules on this table let them do more.'
463
478
  );
464
479
  }
465
480
 
@@ -470,11 +485,11 @@ function bodyFor(finding, contents) {
470
485
  const n = finding.readable || 1;
471
486
  const below = (finding.below || []).map((r) => '"' + r + '"');
472
487
  return (
473
- 'I put one of my fake people into the other one\'s ' + place + ' as a "' + finding.who + '" and, signed ' +
488
+ 'I put one of my fake people into the other one\'s ' + place + ' as ' + aRole(finding.who) + ' and, signed ' +
474
489
  'in as them, read ' + n + (n === 1 ? ' row' : ' rows') + ' of ' + finding.table + ' for that ' + place +
475
490
  ', on a copy of your app - including ' + listOf(finding.secrets || []) + '. Before joining, they could not.' +
476
491
  (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 ' +
492
+ ' Nothing was changed. Whether ' + aRole(finding.who) + ' should see these is your decision: if not, a read ' +
478
493
  'rule on this table lets them.'
479
494
  );
480
495
  }
@@ -778,16 +793,16 @@ function fixPromptFor(finding) {
778
793
  finding.roleColumn + '" is "' + finding.who + '" can ' +
779
794
  listOf((finding.can || []).map((what) => ({ add: 'add', change: 'change', delete: 'delete' })[what])) +
780
795
  ' rows of that ' + oneOf(finding.parent) + ' in the "' + finding.table + '" table. This was proved ' +
781
- 'on a copy of the database: a "' + finding.who + '" was added, and the writes went through.',
796
+ 'on a copy of the database: ' + aRole(finding.who) + ' was added, and the writes went through.',
782
797
  '',
783
- 'First tell me: should a "' + finding.who + '" be able to do that? If yes, change nothing.',
798
+ 'First tell me: should ' + aRole(finding.who) + ' be able to do that? If yes, change nothing.',
784
799
  '',
785
800
  'If not, make the rules on "' + finding.table + '" that cover UPDATE, DELETE, INSERT or ALL check ' +
786
801
  'the member\'s role as well as membership - for example with a helper that reads "' + finding.via +
787
802
  '" for auth.uid() and the roles allowed to edit. Postgres lets a write through if ANY permissive ' +
788
803
  'policy allows it, so a strict rule beside a loose one does nothing: list every such policy on "' +
789
- finding.table + '" and narrow or drop the loose one. Keep the read rule as it is if a "' +
790
- finding.who + '" should still see these rows.',
804
+ finding.table + '" and narrow or drop the loose one. Keep the read rule as it is if ' +
805
+ aRole(finding.who) + ' should still see these rows.',
791
806
  '',
792
807
  'Then check every other table where members can write for the same thing.',
793
808
  ]
@@ -798,9 +813,9 @@ function fixPromptFor(finding) {
798
813
  'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
799
814
  finding.roleColumn + '" is "' + finding.who + '" can read the "' + finding.table + '" table of that ' +
800
815
  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.',
816
+ '. This was proved on a copy of the database: ' + aRole(finding.who) + ' was added, and the rows came back.',
802
817
  '',
803
- 'First tell me: should a "' + finding.who + '" see these? If yes, change nothing.',
818
+ 'First tell me: should ' + aRole(finding.who) + ' see these? If yes, change nothing.',
804
819
  '',
805
820
  'If not, make the rules on "' + finding.table + '" that cover SELECT or ALL check the member\'s role as ' +
806
821
  'well as membership. Postgres lets a read through if ANY permissive policy allows it, so a strict rule ' +
@@ -1049,13 +1064,13 @@ function describe(finding) {
1049
1064
  : finding.kind === 'privileged'
1050
1065
  ? 'I did not call "' + finding.fn + '". This is a reach that exists in the grants, not a break I ran.'
1051
1066
  : finding.kind === 'role'
1052
- ? 'As a "' + finding.who + '" who had just joined, I ' +
1067
+ ? 'As ' + aRole(finding.who) + ' who had just joined, I ' +
1053
1068
  listOf((finding.can || []).map((what) => {
1054
1069
  const n = (finding.changed || {})[what] || 1;
1055
1070
  return ({ add: 'added ', change: 'changed ', delete: 'deleted ' })[what] + n + (n === 1 ? ' row' : ' rows');
1056
1071
  })) + ' in "' + finding.table + '" that I could not touch before joining. All of it was undone.'
1057
1072
  : finding.kind === 'teamread'
1058
- ? 'As a "' + finding.who + '" who had just joined, I read ' + (finding.readable || 1) +
1073
+ ? 'As ' + aRole(finding.who) + ' who had just joined, I read ' + (finding.readable || 1) +
1059
1074
  ((finding.readable || 1) === 1 ? ' row' : ' rows') + ' of "' + finding.table + '" that I could not read before joining.'
1060
1075
  : finding.kind === 'bucket'
1061
1076
  ? 'I read that "' + finding.bucket + '" is public and that ' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon-night",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
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/scan.js CHANGED
@@ -153,7 +153,7 @@ function nodeEngine() {
153
153
  tamper: (client, into, tables, seeded, policies) =>
154
154
  tamper.tamper(client, into, tables, seeded, policies),
155
155
  orphan: (client, into, tables, seeded) => orphan.orphan(client, into, tables, seeded),
156
- teammate: (client, into, tables, seeded) => tamper.teammate(client, into, tables, seeded),
156
+ teammate: (client, into, tables, seeded, rules) => tamper.teammate(client, into, tables, seeded, rules),
157
157
  };
158
158
  }
159
159
 
@@ -305,7 +305,10 @@ async function scan(client, sourceSchema, options) {
305
305
 
306
306
  // What the least trusted member of a team can change there. Rolled back
307
307
  // like the writes above, and the joining with them.
308
- const ranked = await engine.teammate(client, copyName, theirs, sown.seeded);
308
+ // With the copy's rules and helpers, so a role a rule lists as allowed to
309
+ // write is not reported (tamper.allowedByRules).
310
+ const ranked = await engine.teammate(client, copyName, theirs, sown.seeded,
311
+ { policies: copyPlan.policies, functions: copyPlan.functions });
309
312
  for (const key of ranked.completed) attempted.push(key);
310
313
  for (const stuck of ranked.blocked) {
311
314
  notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
package/sqlengine.js CHANGED
@@ -115,10 +115,10 @@ async function tamper(client, target, into, tables, seeded, policies) {
115
115
  }
116
116
 
117
117
  /** What the least trusted member of a team can change. Rolled back inside the engine. */
118
- async function teammate(client, target, into, tables, seeded) {
118
+ async function teammate(client, target, into, tables, seeded, rules) {
119
119
  const { rows } = await client.query(
120
- 'SELECT ' + quote(target) + '.teammate($1, $2::jsonb, $3::jsonb) AS answer',
121
- [into, JSON.stringify(tables), JSON.stringify(seeded)],
120
+ 'SELECT ' + quote(target) + '.teammate($1, $2::jsonb, $3::jsonb, $4::jsonb) AS answer',
121
+ [into, JSON.stringify(tables), JSON.stringify(seeded), JSON.stringify(rules || {})],
122
122
  );
123
123
  return rows[0].answer;
124
124
  }
@@ -171,7 +171,7 @@ function adapterFor(target) {
171
171
  tamper: (client, into, tables, sown, policies) =>
172
172
  tamper(client, target, into, tables, sown, policies),
173
173
  orphan: (client, into, tables, sown) => orphan(client, target, into, tables, sown),
174
- teammate: (client, into, tables, sown) => teammate(client, target, into, tables, sown),
174
+ teammate: (client, into, tables, sown, rules) => teammate(client, target, into, tables, sown, rules),
175
175
  };
176
176
  }
177
177
 
package/tamper.js CHANGED
@@ -289,7 +289,7 @@ function roleColumnOf(table) {
289
289
  // the order reads are tried in, so the least trusted one that can read a
290
290
  // secret is the one named.
291
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 };
292
+ if (lowest) return { column: column.name, type: column.type, lowest: lowest, ladder: ladder };
293
293
  }
294
294
  return null;
295
295
  }
@@ -325,13 +325,51 @@ function memberships(tables) {
325
325
  const keys = attack.foreignKeys(table).concat(attack.impliedKeys(table, tables))
326
326
  .filter((key) => key.refTable !== table.name && names.has(key.refTable) && !key.columns.includes(person));
327
327
  if (!keys.length || aloneUnique(table, person)) continue;
328
- found.push({ table: table, person: person, role: role.column, lowest: role.lowest, ladder: role.ladder, keys: keys });
328
+ found.push({ table: table, person: person, role: role.column, roleType: role.type, lowest: role.lowest,
329
+ ladder: role.ladder, keys: keys });
329
330
  }
330
331
  // A table named for members first: it is the one a team rule asks.
331
332
  const named = (m) => (/member/i.test(m.table.name) ? 0 : 1);
332
333
  return found.map((m, i) => [m, i]).sort((a, b) => named(a[0]) - named(b[0]) || a[1] - b[1]).map((pair) => pair[0]);
333
334
  }
334
335
 
336
+ // What each kind of write is called in a rule.
337
+ const COMMAND = { add: 'INSERT', change: 'UPDATE', delete: 'DELETE' };
338
+
339
+ /**
340
+ * Whether the app's own rules name this role among those allowed to make
341
+ * this kind of write - the way a repository's role rule does it: the role in
342
+ * a list in a permissive rule for that command, has_role(team,
343
+ * ARRAY['owner', 'maintainer', 'developer']), or in a list inside a helper
344
+ * that rule calls (role IN ('owner', 'developer')). A rule written for one
345
+ * role on its own - role = 'analyst', the shape production added in
346
+ * LaunchRail - is not a list of who may write, and stays something to check.
347
+ */
348
+ function allowedByRules(rules, table, what, role, type) {
349
+ const escape = (x) => String(x).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
350
+ const label = "'" + escape(String(role).split("'").join("''")) + "'";
351
+ const typeName = escape(String(type || 'text').split('.').pop().split('"').join(''));
352
+ const inRuleList = new RegExp('ARRAY\\[[^\\]]*' + label + '::("?[A-Za-z0-9_$]+"?\\.)?"?' + typeName + '"?', 'i');
353
+ // The same list written as an array literal - has_role(team,
354
+ // '{owner,admin,member}') - which Postgres keeps as '{...}'::role[].
355
+ const bare = escape(String(role));
356
+ const inRuleLiteral = new RegExp("'\\{([^}']*,)?\"?" + bare + "\"?(,[^}']*)?\\}'::(\"?[A-Za-z0-9_$]+\"?\\.)?\"?" +
357
+ typeName + '"?\\[\\]', 'i');
358
+ const inHelperList = new RegExp('(\\bIN\\s*\\(|ARRAY\\[)[^)\\]]*' + label, 'i');
359
+ const calls = (text, name) => new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?\\s*\\(').test(text);
360
+ const functions = (rules && rules.functions) || [];
361
+ for (const policy of (rules && rules.policies) || []) {
362
+ if (policy.table_name !== table) continue;
363
+ if (String(policy.permissive || 'PERMISSIVE').toUpperCase() !== 'PERMISSIVE') continue;
364
+ const cmd = String(policy.cmd || 'ALL').toUpperCase();
365
+ if (cmd !== 'ALL' && cmd !== COMMAND[what]) continue;
366
+ const text = String(policy.qual || '') + ' ' + String(policy.with_check || '');
367
+ if (inRuleList.test(text) || inRuleLiteral.test(text)) return true;
368
+ if (functions.some((fn) => calls(text, fn.name) && inHelperList.test(String(fn.src || '')))) return true;
369
+ }
370
+ return false;
371
+ }
372
+
335
373
  /**
336
374
  * For every team table: can a viewer change it?
337
375
  *
@@ -341,7 +379,7 @@ function memberships(tables) {
341
379
  * and a person changing their own rows is the feature. Both rolled back, the
342
380
  * joining with them.
343
381
  */
344
- async function teammate(client, schema, tables, seeded) {
382
+ async function teammate(client, schema, tables, seeded, rules) {
345
383
  const findings = [];
346
384
  const completed = [];
347
385
  const blocked = [];
@@ -413,7 +451,6 @@ async function teammate(client, schema, tables, seeded) {
413
451
  for (const table of tables) {
414
452
  if (!shapeFor.has(table.name) || done.has(table.name)) continue;
415
453
  done.add(table.name);
416
- const key = 'role:' + table.name + ':' + member.lowest;
417
454
  const owner = attack.ownerColumn(table);
418
455
  // A table that names the team itself, and the team it is to name.
419
456
  const toTeam = attack.foreignKeys(table).concat(attack.impliedKeys(table, tables))
@@ -448,11 +485,12 @@ async function teammate(client, schema, tables, seeded) {
448
485
  // under the other person's name: a rule that lets a member add a row
449
486
  // only as themselves - a note they wrote - is a member speaking for
450
487
  // themselves, and refuses it (OrbitDesk's internal notes, by design).
451
- if (lookOnly(member.lowest) && toTeam) {
488
+ if (toTeam && member.ladder.some(lookOnly)) {
452
489
  try {
453
490
  const row = await attack.rowFor(client, schema, table, attack.USER_A, 11, pointed, shapeFor.get(table.name));
454
491
  moves.unshift({
455
492
  what: 'add',
493
+ lookOnly: true,
456
494
  statement: row.columns.length
457
495
  ? 'INSERT INTO ' + target + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' +
458
496
  row.values.map((_, i) => '$' + (i + 1)).join(', ') + ')'
@@ -463,39 +501,75 @@ async function teammate(client, schema, tables, seeded) {
463
501
  // Nothing to point a new row at: there is no row to add.
464
502
  }
465
503
  }
466
- const can = [];
467
- const changed = {};
468
- let stuck = null;
504
+
505
+ // Every role not in charge, from the lowest up, the way reads are tried:
506
+ // found on a blind test (LaunchRail), where production gave the
507
+ // "analyst" role its own UPDATE rule on feature flags and only the
508
+ // "viewer" below it was ever tried. The first role that can write where
509
+ // the app's own rules do not list it is the one named; a role a rule
510
+ // lists among those allowed to write is the design, and the roles above
511
+ // it are still tried. What each write did before joining is asked once.
512
+ const beforeJoining = new Map();
469
513
  for (const move of moves) {
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);
472
- const was = counted(before);
473
- const now = counted(after);
474
- if (was === null || now === null) {
475
- stuck = now === null ? after.why : before.why;
476
- } else if (now > was) {
477
- can.push(move.what);
478
- changed[move.what] = now - was;
479
- }
514
+ beforeJoining.set(move.what, await tryWrite(client, 'authenticated', attack.USER_B, move.statement, move.values));
480
515
  }
481
- if (!can.length && stuck) {
482
- blocked.push({ table: table.name, key: key, why: as(member.lowest) + ': ' + stuck });
483
- } else {
516
+ const below = [];
517
+ for (const role of member.ladder) {
518
+ const key = 'role:' + table.name + ':' + role;
519
+ let joined;
520
+ try {
521
+ joined = await joinAs(role);
522
+ } catch (err) {
523
+ blocked.push({ table: table.name, key: key, why: as(role) + ': I could not add one: ' + err.message });
524
+ break;
525
+ }
526
+ const can = [];
527
+ const changed = {};
528
+ let stuck = null;
529
+ // Whether this role could write where the rules list it: then it is
530
+ // not one that "could not", whatever is said of the roles above it.
531
+ let listed = false;
532
+ for (const move of moves) {
533
+ if (move.lookOnly && !lookOnly(role)) continue;
534
+ // A write the rules list this role for is not asked: whatever it
535
+ // did would not be reported, and a key refusing it on the way out
536
+ // left the table untested (HelixOps' assets, as a dispatcher).
537
+ if (allowedByRules(rules, table.name, move.what, role, member.roleType)) {
538
+ listed = true;
539
+ continue;
540
+ }
541
+ const after = await tryWrite(client, 'authenticated', attack.USER_B, move.statement, move.values, joined);
542
+ const was = counted(beforeJoining.get(move.what));
543
+ const now = counted(after);
544
+ if (was === null || now === null) {
545
+ stuck = now === null ? after.why : beforeJoining.get(move.what).why;
546
+ } else if (now > was) {
547
+ can.push(move.what);
548
+ changed[move.what] = now - was;
549
+ }
550
+ }
551
+ if (!can.length && stuck) {
552
+ blocked.push({ table: table.name, key: key, why: as(role) + ': ' + stuck });
553
+ break;
554
+ }
484
555
  completed.push(key);
485
556
  if (can.length) {
486
557
  findings.push({
487
558
  kind: 'role',
488
559
  table: table.name,
489
- who: member.lowest,
560
+ who: role,
490
561
  via: member.table.name,
491
562
  roleColumn: member.role,
492
563
  parent: parent,
493
564
  can: can,
494
565
  changed: changed,
566
+ below: below.slice(),
495
567
  columns: (table.columns || []).map((c) => c.name),
496
568
  rlsEnabled: table.rlsEnabled,
497
569
  });
570
+ break;
498
571
  }
572
+ if (!listed) below.push(role);
499
573
  }
500
574
 
501
575
  // Reading what a member should not see, said only of a table holding a
@@ -552,6 +626,7 @@ module.exports = {
552
626
  writeAsFor: writeAsFor,
553
627
  tamper: tamper,
554
628
  lowestRole: lowestRole,
629
+ allowedByRules: allowedByRules,
555
630
  secretColumns: secretColumns,
556
631
  lookOnly: lookOnly,
557
632
  roleColumnOf: roleColumnOf,