kryptheon-night 0.1.36 → 0.1.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/engine.sql CHANGED
@@ -261,6 +261,9 @@ RETURNS jsonb LANGUAGE sql STABLE AS $$
261
261
  'def', pg_get_functiondef(p.oid),
262
262
  'args', pg_get_function_identity_arguments(p.oid),
263
263
  'default_acl', p.proacl IS NULL,
264
+ -- Installed by an extension, not written by the app (schema.js readFunctions).
265
+ 'from_extension', EXISTS (SELECT 1 FROM pg_depend d
266
+ WHERE d.classid = 'pg_proc'::regclass AND d.objid = p.oid AND d.deptype = 'e'),
264
267
  -- Whether the app can call it at all (schema.js readFunctions).
265
268
  'client_callable', CASE WHEN EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon')
266
269
  AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated')
@@ -365,7 +368,17 @@ BEGIN
365
368
  FROM pg_policies p
366
369
  WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
367
370
  AND p.cmd IN ('SELECT', 'ALL')
368
- AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0)
371
+ AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0),
372
+ -- The rules for putting files in and taking them out (schema.js BUCKETS_QUERY).
373
+ 'writeRules', (SELECT coalesce(jsonb_agg(jsonb_build_object(
374
+ 'name', p.policyname::text,
375
+ 'roles', to_jsonb(p.roles::text[]),
376
+ 'qual', p.qual,
377
+ 'with_check', p.with_check) ORDER BY p.policyname), '[]'::jsonb)
378
+ FROM pg_policies p
379
+ WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
380
+ AND p.cmd IN ('INSERT', 'UPDATE', 'DELETE', 'ALL')
381
+ AND position(quote_literal(b.id::text) IN coalesce(p.qual, '') || ' ' || coalesce(p.with_check, '')) > 0)
369
382
  ) ORDER BY b.id), '[]'::jsonb)
370
383
  FROM storage.buckets b $q$ INTO found;
371
384
  RETURN found;
@@ -513,6 +526,7 @@ DECLARE
513
526
  rest text;
514
527
  narrowing jsonb;
515
528
  no_rule boolean;
529
+ writers jsonb;
516
530
  found jsonb := '[]'::jsonb;
517
531
  BEGIN
518
532
  FOR b IN SELECT * FROM jsonb_array_elements(coalesce(buckets, '[]'::jsonb)) LOOP
@@ -528,12 +542,23 @@ BEGIN
528
542
  narrowing := narrowing || to_jsonb(rule->>'name');
529
543
  END IF;
530
544
  END LOOP;
545
+ -- Rules for putting files in or taking them out that only signed-in
546
+ -- people pass (scan.js bucketsOf; blind round 15).
547
+ writers := '[]'::jsonb;
548
+ IF jsonb_array_length(coalesce(b->'readRules', '[]'::jsonb)) = 0 THEN
549
+ SELECT coalesce(jsonb_agg(w->>'name' ORDER BY ord), '[]'::jsonb) INTO writers
550
+ FROM jsonb_array_elements(coalesce(b->'writeRules', '[]'::jsonb)) WITH ORDINALITY AS e(w, ord)
551
+ WHERE (NOT (coalesce(w->'roles', '[]'::jsonb) ? 'anon' OR coalesce(w->'roles', '[]'::jsonb) ? 'public')
552
+ OR (coalesce(w->>'qual', '') || ' ' || coalesce(w->>'with_check', '')) ~* '\mauth\s*\.\s*(uid|jwt)\s*\(')
553
+ AND (coalesce(w->>'qual', '') || ' ' || coalesce(w->>'with_check', '')) ~* '\mfrom\s+';
554
+ END IF;
531
555
  no_rule := jsonb_array_length(coalesce(b->'readRules', '[]'::jsonb)) = 0
532
- AND (b->>'name') ~* '(^|[-_.])(exports?|private|documents?|docs|invoices?|contracts?|backups?|reports?|receipts?|statements?|payouts?|evidence|kyc|passports?|medical|records?|confidential|internal|secrets?|attachments?)([-_.]|$)';
556
+ AND ((b->>'name') ~* '(^|[-_.])(exports?|private|documents?|docs|invoices?|contracts?|backups?|reports?|receipts?|statements?|payouts?|evidence|kyc|passports?|medical|records?|confidential|internal|secrets?|attachments?)([-_.]|$)'
557
+ OR jsonb_array_length(writers) > 0);
533
558
  IF jsonb_array_length(narrowing) > 0 OR no_rule THEN
534
559
  found := found || jsonb_build_array(jsonb_build_object(
535
560
  'kind', 'bucket', 'table', b->>'name', 'bucket', b->>'id',
536
- 'rules', narrowing, 'noRule', no_rule, 'columns', '[]'::jsonb));
561
+ 'rules', narrowing, 'noRule', no_rule, 'writers', writers, 'columns', '[]'::jsonb));
537
562
  END IF;
538
563
  END LOOP;
539
564
  RETURN found;
@@ -2899,7 +2924,14 @@ BEGIN
2899
2924
  jsonb_build_array(lowest) || coalesce((
2900
2925
  SELECT jsonb_agg(l ORDER BY pos DESC)
2901
2926
  FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, pos)
2902
- WHERE l <> lowest AND l !~* 'owner|admin|super|root'), '[]'::jsonb));
2927
+ WHERE l <> lowest AND l !~* 'owner|admin|super|root'), '[]'::jsonb),
2928
+ -- The role at the top: an owner if there is one, else the first in charge.
2929
+ 'top', coalesce(
2930
+ (SELECT l FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, pos) WHERE l ~* 'owner' ORDER BY pos LIMIT 1),
2931
+ (SELECT l FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, pos) WHERE l ~* 'owner|admin|super|root' ORDER BY pos LIMIT 1)),
2932
+ -- Every role in charge, in the order the list gives them.
2933
+ 'inCharge', coalesce((SELECT jsonb_agg(l ORDER BY pos) FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, pos)
2934
+ WHERE l ~* 'owner|admin|super|root'), '[]'::jsonb));
2903
2935
  END IF;
2904
2936
  END LOOP;
2905
2937
  RETURN NULL;
@@ -2965,7 +2997,7 @@ BEGIN
2965
2997
  CONTINUE WHEN jsonb_array_length(keys) = 0 OR __KN__.alone_unique(tab, person);
2966
2998
  found := found || jsonb_build_array(jsonb_build_object(
2967
2999
  'table', tab, 'person', person, 'role', role_->>'column', 'lowest', role_->>'lowest',
2968
- 'roleType', role_->>'type', 'ladder', role_->'ladder', 'keys', keys));
3000
+ 'roleType', role_->>'type', 'ladder', role_->'ladder', 'top', role_->'top', 'inCharge', role_->'inCharge', 'keys', keys));
2969
3001
  END LOOP;
2970
3002
  -- A table named for members first: it is the one a team rule asks.
2971
3003
  SELECT coalesce(jsonb_agg(m ORDER BY (m->'table'->>'name') !~* 'member', at), '[]'::jsonb) INTO found
@@ -3044,7 +3076,8 @@ DECLARE
3044
3076
  '"?(,[^}'']*)?\}''::("?[A-Za-z0-9_$]+"?\.)?"?' || type_name || '"?\[\]';
3045
3077
  in_helper_list text := '(\mIN\s*\(|ARRAY\[)[^])]*' || label;
3046
3078
  -- A takeover is an UPDATE too (tamper.js COMMAND).
3047
- command text := CASE what WHEN 'add' THEN 'INSERT' WHEN 'change' THEN 'UPDATE' WHEN 'delete' THEN 'DELETE' WHEN 'takeover' THEN 'UPDATE' END;
3079
+ command text := CASE what WHEN 'add' THEN 'INSERT' WHEN 'change' THEN 'UPDATE' WHEN 'delete' THEN 'DELETE'
3080
+ WHEN 'takeover' THEN 'UPDATE' WHEN 'promote' THEN 'UPDATE' END;
3048
3081
  policy jsonb;
3049
3082
  body text;
3050
3083
  BEGIN
@@ -3116,6 +3149,9 @@ DECLARE
3116
3149
  before_all jsonb;
3117
3150
  below jsonb;
3118
3151
  listed boolean;
3152
+ their_team text;
3153
+ plain_ bigint;
3154
+ label_ text;
3119
3155
  BEGIN
3120
3156
  FOR member IN SELECT * FROM jsonb_array_elements(__KN__.memberships(tables)) LOOP
3121
3157
  SELECT (s->>'attempt')::integer INTO shape
@@ -3239,6 +3275,17 @@ BEGIN
3239
3275
  moves := moves || jsonb_build_array(jsonb_build_object('what', 'takeover', 'anyRole', true,
3240
3276
  'statement', 'UPDATE ' || target || ' SET ' || __KN__.always_quote(member->>'person') || ' = '
3241
3277
  || quote_literal(__KN__.user_b()) || ' WHERE ' || array_to_string(where_, ' AND ')));
3278
+ -- Their own membership row, given the role at the top (tamper.js;
3279
+ -- blind round 15: a viewer set their own role to 'owner').
3280
+ IF member->>'top' IS NOT NULL THEN
3281
+ where_ := ARRAY[__KN__.always_quote(member->>'person') || ' = ' || quote_literal(__KN__.user_b())];
3282
+ FOR name IN SELECT jsonb_object_keys(pointed) ORDER BY 1 LOOP
3283
+ where_ := where_ || (__KN__.always_quote(name) || ' = ' || quote_literal(pointed->>name));
3284
+ END LOOP;
3285
+ moves := moves || jsonb_build_array(jsonb_build_object('what', 'promote', 'anyRole', true,
3286
+ 'statement', 'UPDATE ' || target || ' SET ' || __KN__.always_quote(member->>'role') || ' = '
3287
+ || quote_literal(member->>'top') || ' WHERE ' || array_to_string(where_, ' AND ')));
3288
+ END IF;
3242
3289
  END IF;
3243
3290
 
3244
3291
  -- Adding a row to the team, said only of a role whose own name says it
@@ -3322,12 +3369,59 @@ BEGIN
3322
3369
  'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false))
3323
3370
  || CASE WHEN can ? 'takeover'
3324
3371
  THEN jsonb_build_object('takeover', true, 'personColumn', member->>'person')
3372
+ ELSE '{}'::jsonb END
3373
+ || CASE WHEN can ? 'promote'
3374
+ THEN jsonb_build_object('promote', true, 'toRole', member->>'top')
3325
3375
  ELSE '{}'::jsonb END);
3326
3376
  EXIT;
3327
3377
  END IF;
3328
3378
  IF NOT listed THEN below := below || to_jsonb(role_); END IF;
3329
3379
  END LOOP;
3330
3380
 
3381
+ -- In charge of their OWN team, reading the first person's (tamper.js;
3382
+ -- blind round 15: a read rule's role check not tied to the row's
3383
+ -- organization let an admin of any organization read every one). Only
3384
+ -- what the role added counts.
3385
+ their_team := NULL;
3386
+ IF EXISTS (SELECT 1 FROM jsonb_object_keys(pointed)) THEN
3387
+ SELECT string_agg(__KN__.always_quote(k) || ' = ' || quote_literal(pointed->>k), ' AND ' ORDER BY k)
3388
+ INTO their_team FROM jsonb_object_keys(pointed) k;
3389
+ ELSIF owner IS NOT NULL THEN
3390
+ their_team := __KN__.always_quote(owner) || ' = ' || quote_literal(__KN__.user_a());
3391
+ END IF;
3392
+ IF their_team IS NOT NULL AND jsonb_array_length(coalesce(member->'inCharge', '[]'::jsonb)) > 0 THEN
3393
+ look := 'SELECT 1 FROM ' || target || ' WHERE ' || their_team;
3394
+ plain_ := __KN__.rows_moved(__KN__.try_write('authenticated', __KN__.user_b(), look));
3395
+ FOR label_ IN SELECT jsonb_array_elements_text(member->'inCharge') LOOP
3396
+ finding_key := 'elevated:' || (tab->>'name') || ':' || label_;
3397
+ now_ := __KN__.rows_moved(__KN__.try_write_joined('authenticated', __KN__.user_b(), look,
3398
+ 'UPDATE ' || __KN__.always_quote(source) || '.' || __KN__.always_quote(member->'table'->>'name') || ' SET '
3399
+ || __KN__.always_quote(member->>'role') || ' = ' || quote_literal(label_) || ' WHERE '
3400
+ || __KN__.always_quote(member->>'person') || ' = ' || quote_literal(__KN__.user_b())));
3401
+ IF plain_ IS NULL OR now_ IS NULL THEN
3402
+ blocked := blocked || jsonb_build_array(jsonb_build_object('table', tab->>'name', 'key', finding_key,
3403
+ 'why', 'as "' || label_ || '" in their own ' || parent || ' row: reading it failed'));
3404
+ EXIT;
3405
+ END IF;
3406
+ completed := completed || to_jsonb(finding_key);
3407
+ IF now_ > plain_ THEN
3408
+ findings := findings || jsonb_build_array(jsonb_build_object(
3409
+ 'kind', 'crossed',
3410
+ 'table', tab->>'name',
3411
+ 'owner', coalesce(owner, (SELECT k FROM jsonb_object_keys(pointed) k ORDER BY k LIMIT 1)),
3412
+ 'tenant', owner IS NULL,
3413
+ 'readable', now_ - plain_,
3414
+ 'asRole', label_,
3415
+ 'via', member->'table'->>'name',
3416
+ 'roleColumn', member->>'role',
3417
+ 'parent', parent,
3418
+ 'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c), '[]'::jsonb),
3419
+ 'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
3420
+ EXIT;
3421
+ END IF;
3422
+ END LOOP;
3423
+ END IF;
3424
+
3331
3425
  -- Reading what a member should not see, said only of a table holding a
3332
3426
  -- secret. Every role not in charge, from the lowest up; the first that
3333
3427
  -- can read is the one named.
@@ -3852,6 +3946,26 @@ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
3852
3946
  AND m.name IS NOT NULL;
3853
3947
  $$;
3854
3948
 
3949
+ /* Tables nobody can add a row to: every permissive rule for adding rows is a
3950
+ bare INSERT rule, with neither USING nor WITH CHECK, which Postgres reads as
3951
+ "no" (scan.js noInsertsOf; blind round 15's tickets_insert). */
3952
+ CREATE OR REPLACE FUNCTION __KN__.no_insert_findings(plan jsonb)
3953
+ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
3954
+ WITH rules AS (
3955
+ SELECT p->>'table_name' AS t, p->>'name' AS n,
3956
+ upper(coalesce(p->>'cmd', 'ALL')) = 'INSERT' AND p->>'qual' IS NULL AND p->>'with_check' IS NULL AS bare
3957
+ FROM jsonb_array_elements(coalesce(plan->'policies', '[]'::jsonb)) p
3958
+ WHERE upper(coalesce(p->>'permissive', 'PERMISSIVE')) = 'PERMISSIVE'
3959
+ AND upper(coalesce(p->>'cmd', 'ALL')) IN ('INSERT', 'ALL')
3960
+ ), per AS (
3961
+ SELECT t, jsonb_agg(n ORDER BY n COLLATE "C") FILTER (WHERE bare) AS names, bool_and(bare) AS all_bare
3962
+ FROM rules GROUP BY t
3963
+ )
3964
+ SELECT coalesce(jsonb_agg(jsonb_build_object('kind', 'noinsert', 'table', t, 'rules', names, 'columns', '[]'::jsonb)
3965
+ ORDER BY t COLLATE "C"), '[]'::jsonb)
3966
+ FROM per WHERE all_bare;
3967
+ $$;
3968
+
3855
3969
  /* The ones of those that still take an empty value. */
3856
3970
  CREATE OR REPLACE FUNCTION __KN__.empty_choice_findings(plan jsonb)
3857
3971
  RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
@@ -3954,6 +4068,35 @@ BEGIN
3954
4068
  RETURN out_;
3955
4069
  END $$;
3956
4070
 
4071
+ /* The tables whose rules decide who is asking from a setting the app's own
4072
+ server sets - current_setting('app.tenant_id') - not from a Supabase
4073
+ sign-in: NOT checked, said with the settings (scan.js serverIdentityOf,
4074
+ blind round 13). */
4075
+ CREATE OR REPLACE FUNCTION __KN__.server_identity(plan jsonb)
4076
+ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
4077
+ WITH texts AS (
4078
+ SELECT p->>'table_name' AS t, coalesce(p->>'qual', '') || ' ' || coalesce(p->>'with_check', '') AS x
4079
+ FROM jsonb_array_elements(coalesce(plan->'policies', '[]'::jsonb)) p
4080
+ ), settings AS (
4081
+ SELECT t, (regexp_matches(x, 'current_setting\s*\(\s*''([^'']+)''', 'gi'))[1] AS s
4082
+ FROM texts
4083
+ WHERE x !~* '\mauth\s*\.\s*(uid|jwt|role|email)\s*\(|request\.jwt'
4084
+ ), per AS (
4085
+ SELECT t, array_agg(DISTINCT 'current_setting(''' || s || ''')' ORDER BY 'current_setting(''' || s || ''')') AS q
4086
+ FROM settings
4087
+ WHERE s !~* '^request\.'
4088
+ GROUP BY t
4089
+ )
4090
+ SELECT coalesce(jsonb_agg(jsonb_build_object(
4091
+ 'table', t,
4092
+ 'why', 'its rules decide who is asking with '
4093
+ || CASE WHEN cardinality(q) < 2 THEN q[1]
4094
+ ELSE array_to_string(q[1:cardinality(q) - 1], ', ') || ' and ' || q[cardinality(q)] END,
4095
+ 'identity', true)
4096
+ ORDER BY t COLLATE "C"), '[]'::jsonb)
4097
+ FROM per;
4098
+ $$;
4099
+
3957
4100
  /* The app's functions read and never called (scan.js notCalledOf). */
3958
4101
  CREATE OR REPLACE FUNCTION __KN__.not_called(plan jsonb, findings jsonb)
3959
4102
  RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
@@ -3966,6 +4109,7 @@ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
3966
4109
  SELECT coalesce(jsonb_agg(DISTINCT (fn->>'name') COLLATE "C" ORDER BY (fn->>'name') COLLATE "C"), '[]'::jsonb)
3967
4110
  FROM jsonb_array_elements(coalesce(plan->'functions', '[]'::jsonb)) fn, rules
3968
4111
  WHERE coalesce((fn->>'client_callable')::boolean, false)
4112
+ AND NOT coalesce((fn->>'from_extension')::boolean, false)
3969
4113
  AND (fn->>'name') NOT IN (SELECT name FROM named)
3970
4114
  AND NOT __KN__.calls_function(rules.text_, fn->>'name');
3971
4115
  $$;
@@ -4098,6 +4242,7 @@ BEGIN
4098
4242
  || __KN__.search_path_findings(plan)
4099
4243
  || refused
4100
4244
  || __KN__.empty_choice_findings(plan)
4245
+ || __KN__.no_insert_findings(plan)
4101
4246
  || __KN__.bucket_findings(plan->'buckets');
4102
4247
 
4103
4248
  -- Everything genuinely attacked, so a re-check can tell a finding that was
@@ -4116,11 +4261,15 @@ BEGIN
4116
4261
  || coalesce((SELECT jsonb_agg('conflict:' || (f->>'name'))
4117
4262
  FROM jsonb_array_elements(coalesce(plan->'functions', '[]'::jsonb)) f), '[]'::jsonb)
4118
4263
  || coalesce((SELECT jsonb_agg('nullcheck:' || (x->>'table') || ':' || (x->'column'->>'name'))
4119
- FROM jsonb_array_elements(__KN__.one_of_columns(plan)) x), '[]'::jsonb);
4264
+ FROM jsonb_array_elements(__KN__.one_of_columns(plan)) x), '[]'::jsonb)
4265
+ || coalesce((SELECT jsonb_agg(DISTINCT 'noinsert:' || (p->>'table_name'))
4266
+ FROM jsonb_array_elements(coalesce(plan->'policies', '[]'::jsonb)) p
4267
+ WHERE upper(coalesce(p->>'cmd', 'ALL')) IN ('INSERT', 'ALL')), '[]'::jsonb);
4120
4268
 
4121
4269
  -- And everything that was not. Said out loud, never left out: a table
4122
4270
  -- nothing could be put into reads exactly like a table nothing got out of.
4123
4271
  not_checked := coalesce(sown->'skipped', '[]'::jsonb)
4272
+ || __KN__.server_identity(plan)
4124
4273
  || coalesce(seen->'blocked', '[]'::jsonb)
4125
4274
  || coalesce(wrote->'blocked', '[]'::jsonb)
4126
4275
  || coalesce(ranked->'blocked', '[]'::jsonb)
package/finding.js CHANGED
@@ -126,12 +126,15 @@ function severityOf(finding, contents) {
126
126
  // because the app is broken for whoever it fails for, but not a break-in.
127
127
  return 'HIGH';
128
128
  }
129
+ // A member who can make themselves the owner: never the design, and the
130
+ // owner's reach is everything - the one role finding that is confirmed.
131
+ if (finding.kind === 'role' && finding.promote) return 'CRITICAL';
129
132
  if (finding.kind === 'role' || finding.kind === 'teamread' || finding.kind === 'bucket') {
130
133
  // Something to check, never a confirmed break: whether a viewer may edit,
131
134
  // or a bucket may be public, is the owner's decision. So never CRITICAL.
132
135
  return 'HIGH';
133
136
  }
134
- if (finding.kind === 'conflict' || finding.kind === 'searchpath' || finding.kind === 'nullcheck') {
137
+ if (finding.kind === 'conflict' || finding.kind === 'searchpath' || finding.kind === 'nullcheck' || finding.kind === 'noinsert') {
135
138
  // Read from how a function or a column is declared. A refused upsert
136
139
  // breaks the app for whoever calls it; an open search_path and an empty
137
140
  // value are for the owner to weigh. None of them hands a row to anybody.
@@ -373,10 +376,17 @@ function headlineFor(finding) {
373
376
  if (finding.kind === 'nullcheck') {
374
377
  return 'Your ' + finding.table + ' table accepts rows with no ' + finding.column + ' at all.';
375
378
  }
379
+ if (finding.kind === 'noinsert') {
380
+ return 'Nobody can add a row to your ' + finding.table + ' table: its rule for adding rows checks nothing, and Postgres reads that as no.';
381
+ }
376
382
  if (finding.kind === 'role' && finding.takeover) {
377
383
  return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can take another member\'s place in your ' +
378
384
  finding.table + ' table - and with it their role.';
379
385
  }
386
+ if (finding.kind === 'role' && finding.promote) {
387
+ return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can make themselves ' + aRole(finding.toRole) +
388
+ ' by changing their own row in your ' + finding.table + ' table.';
389
+ }
380
390
  if (finding.kind === 'role') {
381
391
  const order = ['delete', 'change', 'add'];
382
392
  const does = order.filter((what) => (finding.can || []).includes(what)).map((what) => WRITE_WORDS[what]);
@@ -387,6 +397,9 @@ function headlineFor(finding) {
387
397
  return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can read your ' + finding.table +
388
398
  ' table, which holds ' + listOf(finding.secrets || []) + '.';
389
399
  }
400
+ if (finding.kind === 'bucket' && finding.noRule && (finding.writers || []).length) {
401
+ return 'Your ' + finding.table + ' storage bucket is public, though only signed-in members may put files in it.';
402
+ }
390
403
  if (finding.kind === 'bucket' && finding.noRule) {
391
404
  return 'Your ' + finding.table + ' storage bucket is public, and its name says its files are not for everyone.';
392
405
  }
@@ -399,6 +412,10 @@ function headlineFor(finding) {
399
412
  // The table name goes in front of the sentence rather than inside it. Put
400
413
  // inside, a table called `customers` produced "One customer can read another
401
414
  // customer's customers", which is the kind of line that loses a reader.
415
+ if (finding.asRole) {
416
+ return capital(aRole(finding.asRole)) + ' of any ' + oneOf(finding.parent) + ' can read every other ' + oneOf(finding.parent) +
417
+ '\'s rows in your ' + finding.table + ' table.';
418
+ }
402
419
  if (finding.tenant) return 'Your ' + finding.table + ' table lets a signed-in user read another organization\'s rows.';
403
420
  return 'Your ' + finding.table + ' table lets one customer read another one\'s rows.';
404
421
  }
@@ -491,6 +508,19 @@ function bodyFor(finding, contents) {
491
508
  );
492
509
  }
493
510
 
511
+ if (finding.kind === 'role' && finding.promote) {
512
+ // Never the design: a member who can raise their own role has every
513
+ // right the top role has, in every table that trusts the role.
514
+ const place = oneOf(finding.parent);
515
+ return (
516
+ 'I put one of my fake people into the other one\'s ' + place + ' as ' + aRole(finding.who) +
517
+ ' in ' + finding.via + '.' + finding.roleColumn + ' and, signed in as them, changed ' + finding.via + '.' +
518
+ finding.roleColumn + ' on their own membership row to "' + finding.toRole + '" - on a copy of your app. It was ' +
519
+ 'undone straight away. From then on they would have every right ' + aRole(finding.toRole) + ' has, in every ' +
520
+ 'table and function that trusts that role.'
521
+ );
522
+ }
523
+
494
524
  if (finding.kind === 'role') {
495
525
  // Said as what was done, and then whose decision it is: a viewer who may
496
526
  // edit is a hole in one app and the design of another.
@@ -535,8 +565,13 @@ function bodyFor(finding, contents) {
535
565
  return (
536
566
  'In Supabase Storage the bucket "' + finding.bucket + '" is set to public, so anyone with a ' +
537
567
  'file\'s link can download it without logging in - the rules on storage.objects are not asked. ' +
538
- 'No rule says who may read it, but its name says what it holds, and that does not sound like ' +
539
- 'something for everyone. I only read the bucket\'s setting and its rules; I did not open or list ' +
568
+ ((finding.writers || []).length
569
+ ? 'No rule says who may read it, but ' + listOf((finding.writers || []).map((r) => '"' + r + '"')) +
570
+ ' let only signed-in members put files in or take them out - so the app treats these files as its ' +
571
+ 'members\' own, while anyone holding a link can download them. '
572
+ : 'No rule says who may read it, but its name says what it holds, and that does not sound like ' +
573
+ 'something for everyone. ') +
574
+ 'I only read the bucket\'s setting and its rules; I did not open or list ' +
540
575
  'any file. Whether it should be public is yours to say.'
541
576
  );
542
577
  }
@@ -587,6 +622,17 @@ function bodyFor(finding, contents) {
587
622
  'anything; this is read from how the column is declared.'
588
623
  );
589
624
  }
625
+ if (finding.kind === 'noinsert') {
626
+ return (
627
+ 'Row level security is on for ' + finding.table + ', and the only ' + ((finding.rules || []).length === 1 ? 'rule' : 'rules') +
628
+ ' for adding rows (' + listOf((finding.rules || []).map((r) => '"' + r + '"')) + ') ' +
629
+ ((finding.rules || []).length === 1 ? 'has' : 'have') + ' neither USING nor WITH CHECK. A rule with no ' +
630
+ 'expression is not "allow everything": Postgres treats it as false, so every INSERT - from the app, ' +
631
+ 'and from any function that runs as the user - is refused with "new row violates row-level security ' +
632
+ 'policy". If your app adds ' + finding.table + ' rows, that feature is broken for everyone. This is read ' +
633
+ 'from how the rule is written; I did not insert anything.'
634
+ );
635
+ }
590
636
  if (finding.kind === 'privileged') {
591
637
  // Said as what is true, not as what was done - nothing was executed. The
592
638
  // reach is real; whether it is a mistake is the person's to say, so the
@@ -644,6 +690,17 @@ function bodyFor(finding, contents) {
644
690
  (holds ? ', including ' + holds : '') + '.'
645
691
  );
646
692
  }
693
+ if (finding.asRole) {
694
+ // Blind round 15: a role check in a read rule that was never tied to the
695
+ // row's own organization.
696
+ return (
697
+ 'I made one of my fake people ' + aRole(finding.asRole) + ' of their own ' + oneOf(finding.parent) + ' (' + finding.via + '.' +
698
+ finding.roleColumn + ') and, signed in as them, asked for the other ' + oneOf(finding.parent) + '\'s rows in ' + finding.table +
699
+ ' and got ' + finding.readable + ' back' + (holds ? ', including ' + holds : '') + '. With an ordinary role they got none. ' +
700
+ 'So anybody who is ' + aRole(finding.asRole) + ' anywhere - which in most apps means anybody who creates their own ' +
701
+ oneOf(finding.parent) + ' - can read every ' + oneOf(finding.parent) + '\'s rows here.'
702
+ );
703
+ }
647
704
  if (finding.tenant) {
648
705
  return (
649
706
  'Signed in as someone in a different organization, I asked for another organization\'s rows and got ' +
@@ -898,6 +955,23 @@ function fixPromptFor(finding) {
898
955
  '',
899
956
  'Then check every function that updates "' + finding.via + '" for the same thing.',
900
957
  ]
958
+ : finding.kind === 'role' && finding.promote
959
+ ? [
960
+ 'My app has a security problem - a member can make themselves ' + aRole(finding.toRole) + '.',
961
+ '',
962
+ 'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
963
+ finding.roleColumn + '" is "' + finding.who + '" can update their own row in "' + finding.via + '" and set "' +
964
+ finding.roleColumn + '" to "' + finding.toRole + '". The rule only checks that the row is theirs, not what ' +
965
+ 'they change in it. This was proved on a copy of the database.',
966
+ '',
967
+ 'Fix the rules on "' + finding.via + '" that cover UPDATE or ALL: a member must never be able to change "' +
968
+ finding.roleColumn + '" on their own row. Let only the roles that manage the team (usually owner and admin) ' +
969
+ 'change roles, checked in WITH CHECK as well as USING - for example with a helper that reads "' + finding.via +
970
+ '" for auth.uid() - and make sure nobody can raise anyone above their own role. If members need to edit ' +
971
+ 'something else of their own, allow exactly those columns and not "' + finding.roleColumn + '".',
972
+ '',
973
+ 'Then check every function that updates "' + finding.via + '" for the same thing.',
974
+ ]
901
975
  : finding.kind === 'role'
902
976
  ? [
903
977
  'My app may have a permissions problem - please check it rather than assume it.',
@@ -943,7 +1017,11 @@ function fixPromptFor(finding) {
943
1017
  'My app may have a storage problem - please check it rather than assume it.',
944
1018
  '',
945
1019
  'The Supabase Storage bucket "' + finding.bucket + '" is public. Anyone with a file\'s URL can ' +
946
- 'download it without logging in' + (finding.noRule
1020
+ 'download it without logging in' + (finding.noRule && (finding.writers || []).length
1021
+ ? ', and no rule on storage.objects says who may read it - though ' +
1022
+ listOf((finding.writers || []).map((r) => '"' + r + '"')) + ' let only signed-in members upload or delete ' +
1023
+ 'its files, which suggests they are not meant for everyone.'
1024
+ : finding.noRule
947
1025
  ? ', and no rule on storage.objects says who may read it - its name suggests its files are not ' +
948
1026
  'meant for everyone.'
949
1027
  : ', and the storage.objects ' +
@@ -1019,6 +1097,24 @@ function fixPromptFor(finding) {
1019
1097
  'the right value is), then ALTER TABLE "' + finding.table + '" ALTER COLUMN "' + finding.column +
1020
1098
  '" SET NOT NULL. Change nothing else.',
1021
1099
  ]
1100
+ : finding.kind === 'noinsert'
1101
+ ? [
1102
+ 'My app may have a bug - please check it rather than assume it.',
1103
+ '',
1104
+ 'In my database, the rule' + ((finding.rules || []).length === 1 ? ' ' : 's ') +
1105
+ listOf((finding.rules || []).map((r) => '"' + r + '"')) + ' on "' + finding.table + '" for INSERT ' +
1106
+ ((finding.rules || []).length === 1 ? 'has' : 'have') + ' no WITH CHECK, and there is no other rule that ' +
1107
+ 'allows adding rows. Postgres treats a missing check as false, so nobody can add a row to "' + finding.table +
1108
+ '" - not even through a function that runs as the user.',
1109
+ '',
1110
+ 'First tell me: is adding rows to "' + finding.table + '" meant to work for signed-in users? If only your ' +
1111
+ 'server adds them, change nothing.',
1112
+ '',
1113
+ 'If users should be able to add them: give the rule a WITH CHECK that says who may - for example that ' +
1114
+ 'the user is a member of the row\'s organization (through your membership table or a helper) and that ' +
1115
+ 'the column naming who created it equals auth.uid(). Never use WITH CHECK (true): that lets anyone add ' +
1116
+ 'rows anywhere. Then add one row through the app to make sure it works.',
1117
+ ]
1022
1118
  : finding.kind === 'privileged'
1023
1119
  ? [
1024
1120
  'My app may have a security problem - please check it rather than assume it.',
@@ -1166,7 +1262,23 @@ function fixPromptFor(finding) {
1166
1262
  'Then look for the same missing constraint on every other table and fix those too.',
1167
1263
  ],
1168
1264
  )
1169
- : finding.isView
1265
+ : finding.asRole
1266
+ ? [
1267
+ 'My app has a security problem.',
1268
+ '',
1269
+ 'In "' + finding.table + '", anyone who is "' + finding.asRole + '" in their own ' + oneOf(finding.parent) + ' can read the rows ' +
1270
+ 'of every ' + oneOf(finding.parent) + '. I proved it on a copy: as "' + finding.asRole + '" of one ' + oneOf(finding.parent) +
1271
+ ', I read ' + finding.readable + ' row(s) of another.',
1272
+ '',
1273
+ 'A rule on "' + finding.table + '" checks the caller\'s role without tying it to the row - for example ' +
1274
+ 'exists (select 1 from ' + finding.via + ' m where m.' + finding.roleColumn + ' = \'' + finding.asRole + '\') with no ' +
1275
+ 'condition that m is the signed-in user\'s membership of THIS row\'s ' + oneOf(finding.parent) + '. Fix every rule on "' +
1276
+ finding.table + '": a role check must say m.user_id = auth.uid() AND m.<' + oneOf(finding.parent) + ' id> = the row\'s ' +
1277
+ oneOf(finding.parent) + ' id - or call a helper that does. Keep membership as the way in; do not replace it with USING (true).',
1278
+ '',
1279
+ 'Then check every other rule and helper that checks a role for the same missing link.',
1280
+ ]
1281
+ : finding.isView
1170
1282
  ? [
1171
1283
  'My app has a security problem.',
1172
1284
  '',
@@ -1209,7 +1321,7 @@ function fixPromptFor(finding) {
1209
1321
 
1210
1322
  // Found and said, but never claimed as a break: the owner decides. Counted
1211
1323
  // apart in the report, never towards the exit code or a clean re-check.
1212
- const TO_CHECK = ['privileged', 'role', 'teamread', 'bucket', 'searchpath', 'nullcheck'];
1324
+ const TO_CHECK = ['privileged', 'role', 'teamread', 'bucket', 'searchpath', 'nullcheck', 'noinsert'];
1213
1325
  // Functions, said by their name rather than a table's.
1214
1326
  const ON_A_FUNCTION = ['privileged', 'searchpath', 'conflict'];
1215
1327
 
@@ -1223,7 +1335,7 @@ function describe(finding) {
1223
1335
  // one the tool reasons about without executing, so it says so. The report
1224
1336
  // and the re-check both read this: a "verification required" finding is
1225
1337
  // never counted towards a clean re-check on its own.
1226
- status: TO_CHECK.includes(finding.kind) ? 'verification required' : 'confirmed',
1338
+ status: TO_CHECK.includes(finding.kind) && !(finding.kind === 'role' && finding.promote) ? 'verification required' : 'confirmed',
1227
1339
  table: ON_A_FUNCTION.includes(finding.kind) ? finding.fn : finding.table,
1228
1340
  fn: finding.fn,
1229
1341
  args: finding.args,
@@ -1250,14 +1362,19 @@ function describe(finding) {
1250
1362
  readable: finding.readable,
1251
1363
  secrets: finding.secrets,
1252
1364
  bucket: finding.bucket,
1365
+ rules: finding.rules,
1253
1366
  // An organization's rows read from outside it, and a member taking over
1254
1367
  // another member's row: kept so the re-check and the read-back word them
1255
1368
  // the same way the report did.
1256
1369
  tenant: finding.tenant,
1370
+ asRole: finding.asRole,
1257
1371
  takeover: finding.takeover,
1258
1372
  personColumn: finding.personColumn,
1373
+ promote: finding.promote,
1374
+ toRole: finding.toRole,
1259
1375
  nullSlips: finding.nullSlips,
1260
1376
  noRule: finding.noRule,
1377
+ writers: finding.writers,
1261
1378
  // Read from how a function or column is declared (scan.js searchPathsOf,
1262
1379
  // conflictsIn, emptyChoicesOf); kept so the read-back says it the same way.
1263
1380
  target: finding.target,
@@ -1272,6 +1389,9 @@ function describe(finding) {
1272
1389
  ') on the copy, and it refused. I did not call the function.'
1273
1390
  : finding.kind === 'nullcheck'
1274
1391
  ? 'I read that "' + finding.table + '.' + finding.column + '" may be NULL and has no default. I did not insert anything.'
1392
+ : finding.kind === 'noinsert'
1393
+ ? 'I read that every rule for adding rows to "' + finding.table + '" (' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
1394
+ ') has neither USING nor WITH CHECK. I did not insert anything.'
1275
1395
  : finding.kind === 'recursive'
1276
1396
  ? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
1277
1397
  loopError(finding) + '.'
@@ -1280,6 +1400,12 @@ function describe(finding) {
1280
1400
  : finding.kind === 'role' && finding.takeover
1281
1401
  ? 'As ' + aRole(finding.who) + ' who had just joined, I put my own id on the other person\'s row of "' +
1282
1402
  finding.via + '", which I could not touch before joining. It was undone.'
1403
+ : finding.kind === 'role' && finding.promote
1404
+ ? 'As ' + aRole(finding.who) + ' who had just joined, I set "' + finding.roleColumn + '" on my own row of "' +
1405
+ finding.via + '" to "' + finding.toRole + '". It was undone.'
1406
+ : finding.kind === 'crossed' && finding.asRole
1407
+ ? 'As "' + finding.asRole + '" of my own ' + oneOf(finding.parent) + ', I read ' + finding.readable + ' row(s) of "' +
1408
+ finding.table + '" belonging to another; with an ordinary role, none. My role was put back.'
1283
1409
  : finding.kind === 'role'
1284
1410
  ? 'As ' + aRole(finding.who) + ' who had just joined, I ' +
1285
1411
  listOf((finding.can || []).map((what) => {
@@ -1290,7 +1416,9 @@ function describe(finding) {
1290
1416
  ? 'As ' + aRole(finding.who) + ' who had just joined, I read ' + (finding.readable || 1) +
1291
1417
  ((finding.readable || 1) === 1 ? ' row' : ' rows') + ' of "' + finding.table + '" that I could not read before joining.'
1292
1418
  : finding.kind === 'bucket' && finding.noRule
1293
- ? 'I read that "' + finding.bucket + '" is public and that no rule says who may read it. I did not open any file.'
1419
+ ? 'I read that "' + finding.bucket + '" is public and that no rule says who may read it' +
1420
+ ((finding.writers || []).length ? ', while ' + listOf((finding.writers || []).map((r) => '"' + r + '"')) +
1421
+ ' limit uploads and deletes to signed-in members' : '') + '. I did not open any file.'
1294
1422
  : finding.kind === 'bucket'
1295
1423
  ? 'I read that "' + finding.bucket + '" is public and that ' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
1296
1424
  ' says who may read it. I did not open any file.'
@@ -1398,7 +1526,7 @@ function describeAll(findings) {
1398
1526
  // Writes above reads: a table somebody emptied is worse than one they read.
1399
1527
  // A looping rule sits with the reads: it breaks the app for everyone it
1400
1528
  // fails for, which matters more than a duplicate or a stray row.
1401
- const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, conflict: 2.6, duplicated: 3, orphaned: 4, role: 4.5, teamread: 4.6, bucket: 4.7, privileged: 5, searchpath: 5.5, nullcheck: 6 };
1529
+ const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, conflict: 2.6, duplicated: 3, orphaned: 4, role: 4.5, teamread: 4.6, bucket: 4.7, privileged: 5, searchpath: 5.5, noinsert: 5.7, nullcheck: 6 };
1402
1530
  const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
1403
1531
  return described.sort((a, b) => rank(a) - rank(b));
1404
1532
  }
package/outside.js CHANGED
@@ -79,12 +79,26 @@ async function discover(input, options) {
79
79
  if (!appUrl) throw problem('no-app', 'I need the address of your running app, e.g. https://your-app.example.com');
80
80
 
81
81
  const html = await getText(doFetch, appUrl);
82
- const scripts = scriptUrls(html, appUrl);
83
82
  // The HTML itself sometimes carries the values (server-rendered apps do), so
84
83
  // it is searched too, before the bundles.
85
84
  let text = html;
86
- for (const src of scripts) {
87
- text += '\n' + await getText(doFetch, src).catch(() => '');
85
+ // The scripts the page loads, and the app's own modules those import - a
86
+ // page with no build step keeps its address and key in a config.js that
87
+ // app.js imports. Found on blind round 15: src/app.js did
88
+ // `import { SUPABASE_URL, SUPABASE_ANON_KEY } from "./config.js"`, and the
89
+ // URL mode said there was no Supabase here. Only the app's own address is
90
+ // followed, never a CDN, and only so far.
91
+ const origin = new URL(appUrl).origin;
92
+ const queue = scriptUrls(html, appUrl);
93
+ const seen = new Set(queue);
94
+ for (let i = 0; i < queue.length && i < MAX_SCRIPTS; i++) {
95
+ const body = await getText(doFetch, queue[i]).catch(() => '');
96
+ text += '\n' + body;
97
+ for (const next of importUrls(body, queue[i])) {
98
+ if (seen.has(next) || new URL(next).origin !== origin) continue;
99
+ seen.add(next);
100
+ queue.push(next);
101
+ }
88
102
  }
89
103
 
90
104
  const supabaseUrl = firstMatch(text, /https?:\/\/[a-z0-9-]+\.supabase\.co/i);
@@ -242,6 +256,31 @@ function scriptUrls(html, appUrl) {
242
256
  return dedupe(out);
243
257
  }
244
258
 
259
+ // How many scripts a page and its own imports may add up to. A built app is a
260
+ // handful; this stops a page that imports itself in circles, or a huge
261
+ // unbundled tree, from being read without end.
262
+ const MAX_SCRIPTS = 40;
263
+
264
+ // The modules a script imports by a path - import ... from "./x.js",
265
+ // export ... from "./x.js", import("./x.js") - made absolute against the
266
+ // script's own address. A bare name ("react") is resolved by a bundler or an
267
+ // import map, not by a path, so it is not one.
268
+ function importUrls(source, scriptUrl) {
269
+ const out = [];
270
+ const re = /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)["']([^"']+)["']/g;
271
+ let m;
272
+ while ((m = re.exec(String(source || '')))) {
273
+ const spec = m[1];
274
+ if (!/^(\.{1,2}\/|\/|https?:\/\/)/.test(spec)) continue;
275
+ try {
276
+ out.push(new URL(spec, scriptUrl).href);
277
+ } catch (err) {
278
+ /* not a path we can follow */
279
+ }
280
+ }
281
+ return dedupe(out);
282
+ }
283
+
245
284
  function firstMatch(text, re) {
246
285
  const m = String(text).match(re);
247
286
  return m ? m[0] : null;
@@ -403,6 +442,7 @@ module.exports = {
403
442
  prowl: prowl,
404
443
  // exported for the checks
405
444
  scriptUrls: scriptUrls,
445
+ importUrls: importUrls,
406
446
  tableNames: tableNames,
407
447
  anonJwt: anonJwt,
408
448
  rangeCount: rangeCount,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon-night",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "description": "Attacks a copy of your Supabase database and tells you in plain English what got in.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "commonjs",
@@ -31,7 +31,7 @@
31
31
  "node": ">=18.0.0"
32
32
  },
33
33
  "scripts": {
34
- "check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node recursion.check.js && node guarded.check.js && node nightly.check.js && node words.check.js && node helpers.check.js && node deeploop.check.js && node teams.check.js && node roles.check.js && node buckets.check.js && node cli.check.js && node loop.check.js && node report.check.js && node installscript.check.js && node tenants.check.js && node takeover.check.js && node checkfit.check.js && node declared.check.js",
34
+ "check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node recursion.check.js && node guarded.check.js && node nightly.check.js && node words.check.js && node helpers.check.js && node deeploop.check.js && node teams.check.js && node roles.check.js && node buckets.check.js && node cli.check.js && node loop.check.js && node report.check.js && node installscript.check.js && node tenants.check.js && node takeover.check.js && node elevated.check.js && node checkfit.check.js && node declared.check.js && node identity.check.js",
35
35
  "check:dry": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node recheck.check.js && node outside.check.js",
36
36
  "check:package": "node package.check.js",
37
37
  "check:store": "node store.check.js",
@@ -78,7 +78,9 @@
78
78
  "check:installscript": "node installscript.check.js",
79
79
  "check:tenants": "node tenants.check.js",
80
80
  "check:takeover": "node takeover.check.js",
81
- "check:checkfit": "node checkfit.check.js"
81
+ "check:checkfit": "node checkfit.check.js",
82
+ "check:identity": "node identity.check.js",
83
+ "check:elevated": "node elevated.check.js"
82
84
  },
83
85
  "keywords": [
84
86
  "supabase",
package/recheck.js CHANGED
@@ -232,6 +232,7 @@ function whatOf(item) {
232
232
  if (item.kind === 'searchpath') return 'full rights, search_path not pinned';
233
233
  if (item.kind === 'conflict') return 'an upsert Postgres refuses';
234
234
  if (item.kind === 'nullcheck') return 'a ' + (item.column || 'value') + ' that can be left empty';
235
+ if (item.kind === 'noinsert') return 'no row can be added';
235
236
  return item.kind;
236
237
  }
237
238
 
@@ -315,6 +316,8 @@ function describe(result) {
315
316
  : item.kind === 'conflict'
316
317
  ? (elsewhere ? 'I asked Postgres to plan it here, and it accepted it.'
317
318
  : 'I asked Postgres to plan it again, and it accepted it.')
319
+ : item.kind === 'noinsert'
320
+ ? (elsewhere ? 'I looked here, and a rule for adding rows checks something.' : 'I looked again, and a rule for adding rows now checks something.')
318
321
  : item.kind === 'nullcheck'
319
322
  ? (elsewhere ? 'I looked here, and it cannot be left empty.' : 'I looked again, and it can no longer be left empty.')
320
323
  : item.kind === 'recursive'
package/scan.js CHANGED
@@ -285,7 +285,7 @@ async function scan(client, sourceSchema, options) {
285
285
  // Surfaced, not swallowed. A table with no row in it reads as a safe
286
286
  // table, so the one thing that must never happen is reporting an app as
287
287
  // clear when part of it was never actually tried.
288
- const notChecked = sown.skipped.slice();
288
+ const notChecked = sown.skipped.slice().concat(serverIdentityOf(plan));
289
289
 
290
290
  // A read that errored for any reason other than the table being closed to
291
291
  // that role decided nothing at all, and zero rows back from a failed query
@@ -419,7 +419,11 @@ async function scan(client, sourceSchema, options) {
419
419
  for (const fn of plan.anonFunctions || []) attempted.push('searchpath:' + fn.name);
420
420
  for (const fn of plan.functions || []) attempted.push('conflict:' + fn.name);
421
421
  for (const x of oneOfColumns(plan)) attempted.push('nullcheck:' + x.table + ':' + x.column.name);
422
- const declared = searchPathsOf(plan).concat(refused, emptyChoicesOf(plan));
422
+ // Only a table that has a rule for adding rows was looked at for this.
423
+ for (const name of new Set((plan.policies || []).filter((p) => /^(INSERT|ALL)$/i.test(String(p.cmd || 'ALL'))).map((p) => p.table_name))) {
424
+ attempted.push('noinsert:' + name);
425
+ }
426
+ const declared = searchPathsOf(plan).concat(refused, emptyChoicesOf(plan), noInsertsOf(plan));
423
427
 
424
428
  // Storage buckets switched to public that also have a rule saying who may
425
429
  // read them. Read from the bucket settings and the rules, never a file.
@@ -573,6 +577,34 @@ const ONE_OF = /^CHECK \(+"?([A-Za-z_][\w$]*)"?\)?(?:::[\w ]+)? = ANY \(/;
573
577
  * fills the column: no default, not generated, and a CHECK that does not
574
578
  * mention NULL itself (a CHECK that does has decided the question).
575
579
  */
580
+ /**
581
+ * Tables nobody can add a row to: every rule for adding rows is an INSERT
582
+ * rule written with neither USING nor WITH CHECK, which Postgres reads as
583
+ * "no" - measured on PostgreSQL 18, a bare `create policy p on t for insert
584
+ * to authenticated;` refuses every insert. A rule for ALL, or one with a
585
+ * check, is a way in, and the table is left alone. Mirrors no_insert_findings
586
+ * in engine.sql.
587
+ *
588
+ * Found on blind round 15 (Northstar Ops on Supabase): tickets_insert was
589
+ * written that way, so create_ticket failed for every user - the app's main
590
+ * feature - and nothing in the report said so.
591
+ */
592
+ function noInsertsOf(plan) {
593
+ const byTable = new Map();
594
+ for (const p of (plan && plan.policies) || []) {
595
+ if (String(p.permissive || 'PERMISSIVE').toUpperCase() !== 'PERMISSIVE') continue;
596
+ const cmd = String(p.cmd || 'ALL').toUpperCase();
597
+ if (cmd !== 'INSERT' && cmd !== 'ALL') continue;
598
+ if (!byTable.has(p.table_name)) byTable.set(p.table_name, { bare: [], open: false });
599
+ const entry = byTable.get(p.table_name);
600
+ if (cmd === 'INSERT' && p.qual == null && p.with_check == null) entry.bare.push(p.name);
601
+ else entry.open = true;
602
+ }
603
+ return Array.from(byTable.keys()).sort()
604
+ .filter((table) => byTable.get(table).bare.length && !byTable.get(table).open)
605
+ .map((table) => ({ kind: 'noinsert', table: table, rules: byTable.get(table).bare.slice().sort(), columns: [] }));
606
+ }
607
+
576
608
  function emptyChoicesOf(plan) {
577
609
  return oneOfColumns(plan)
578
610
  .filter((x) => !x.column.not_null && x.column.default_expr == null && !x.column.generated)
@@ -693,6 +725,44 @@ async function conflictsIn(client, into, plan, indexes) {
693
725
  return found;
694
726
  }
695
727
 
728
+ /**
729
+ * The tables whose rules decide who is asking from a setting the app's own
730
+ * server sets - current_setting('app.tenant_id') - rather than from a
731
+ * Supabase sign-in. Each is a table NOT checked, said with the settings.
732
+ *
733
+ * Found on blind round 13 (Northstar Ops, Express on plain Postgres): every
734
+ * rule read app.tenant_id / app.user_id, which nothing here sets. Every attack
735
+ * ran as nobody, every one was refused for that reason alone, and the report
736
+ * said "Nothing got through" over a table whose rule was USING (true) - the
737
+ * one sentence this program must never print by accident. A rule that also
738
+ * asks Supabase (auth.uid(), request.jwt...) is attacked as usual.
739
+ * Mirrors server_identity in engine.sql.
740
+ */
741
+ function serverIdentityOf(plan) {
742
+ const byTable = new Map();
743
+ for (const p of (plan && plan.policies) || []) {
744
+ const text = String(p.qual || '') + ' ' + String(p.with_check || '');
745
+ if (/\bauth\s*\.\s*(uid|jwt|role|email)\s*\(|request\.jwt/i.test(text)) continue;
746
+ const re = /current_setting\s*\(\s*'([^']+)'/gi;
747
+ let m;
748
+ while ((m = re.exec(text))) {
749
+ if (/^request\./i.test(m[1])) continue;
750
+ if (!byTable.has(p.table_name)) byTable.set(p.table_name, new Set());
751
+ byTable.get(p.table_name).add(m[1]);
752
+ }
753
+ }
754
+ return Array.from(byTable.keys()).sort().map((table) => {
755
+ const settings = Array.from(byTable.get(table)).sort();
756
+ // The why is short and per table; what it means is said once, under the
757
+ // list (notTestedLines), not repeated for every table.
758
+ return {
759
+ table: table,
760
+ why: 'its rules decide who is asking with ' + andList(settings.map((s) => "current_setting('" + s + "')")),
761
+ identity: true,
762
+ };
763
+ });
764
+ }
765
+
696
766
  /**
697
767
  * The app's functions that were read and never called: anything a visitor or
698
768
  * a signed-in user can call, that no rule calls for them (those ran with every
@@ -707,7 +777,7 @@ function notCalledOf(plan, findings) {
707
777
  const rules = ((plan && plan.policies) || []).map((p) => String(p.qual || '') + ' ' + String(p.with_check || '')).join(' ');
708
778
  const named = new Set((findings || []).map((f) => f.fn).filter(Boolean));
709
779
  const names = ((plan && plan.functions) || [])
710
- .filter((fn) => fn.client_callable === true && !named.has(fn.name))
780
+ .filter((fn) => fn.client_callable === true && fn.from_extension !== true && !named.has(fn.name))
711
781
  .map((fn) => fn.name)
712
782
  .filter((name) => !new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?\\s*\\(').test(rules));
713
783
  return Array.from(new Set(names)).sort();
@@ -735,18 +805,35 @@ function bucketsOf(plan) {
735
805
  .replace(/\btrue\b|\band\b|[()\s]/gi, '');
736
806
  return !toEveryone || rest !== '';
737
807
  };
738
- return ((plan && plan.buckets) || []).filter((b) => b.public).map((b) => ({
739
- kind: 'bucket',
740
- table: b.name,
741
- bucket: b.id,
742
- rules: (b.readRules || []).filter((rule) => narrows(rule, b.id)).map((rule) => rule.name),
743
- // No read rule at all, and a name that says what is in it is not for
744
- // everyone. Found on a blind test (HarborLine): customer-exports was
745
- // public, and its one read rule had failed to load (a text compared to a
746
- // uuid) - with nothing to read, the bucket was never mentioned.
747
- noRule: !(b.readRules || []).length && PRIVATE_BUCKET.test(String(b.name)),
748
- columns: [],
749
- })).filter((f) => f.rules.length || f.noRule);
808
+ // A rule for putting files in or taking them out that only signed-in people
809
+ // pass AND that looks a file up in the app's own data (a subquery over a
810
+ // table: an attachments row, a membership). That is files the app treats as
811
+ // its members' records. A rule that only asks whose folder it is - the
812
+ // usual public avatars bucket - is not this.
813
+ const membersOnly = (rule) => {
814
+ const roles = schema.roleList(rule.roles);
815
+ const text = String(rule.qual || '') + ' ' + String(rule.with_check || '');
816
+ const open = roles.includes('anon') || roles.includes('public');
817
+ return (!open || /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(text)) && /\bfrom\s+/i.test(text);
818
+ };
819
+ return ((plan && plan.buckets) || []).filter((b) => b.public).map((b) => {
820
+ const writers = (b.writeRules || []).filter(membersOnly).map((rule) => rule.name);
821
+ return {
822
+ kind: 'bucket',
823
+ table: b.name,
824
+ bucket: b.id,
825
+ rules: (b.readRules || []).filter((rule) => narrows(rule, b.id)).map((rule) => rule.name),
826
+ // No read rule at all, and something else saying its files are not for
827
+ // everyone: a name that says what is in it (HarborLine: customer-exports,
828
+ // whose one read rule had failed to load), or rules that let only
829
+ // signed-in members put files in or take them out (blind round 15:
830
+ // workspace-files, written only through an attachments row of the
831
+ // uploader's, and readable by anyone with a link).
832
+ noRule: !(b.readRules || []).length && (PRIVATE_BUCKET.test(String(b.name)) || writers.length > 0),
833
+ writers: !(b.readRules || []).length ? writers : [],
834
+ columns: [],
835
+ };
836
+ }).filter((f) => f.rules.length || f.noRule);
750
837
  }
751
838
 
752
839
  // Names that say a bucket's files are somebody's own business. Whole words
@@ -915,8 +1002,17 @@ function notTestedLines(result) {
915
1002
  lines.push(' Every table I could reach was attacked, and every attack I have');
916
1003
  lines.push(' finished, ran.');
917
1004
  } else {
1005
+ // Wrapped: a reason can be a sentence or two (serverIdentityOf), and an
1006
+ // unwrapped one ran off the side of the terminal and the dashboard.
918
1007
  for (const missed of notChecked) {
919
- lines.push(' ' + missed.table + ' - ' + missed.why);
1008
+ wrap(missed.table + ' - ' + missed.why, 66).forEach((l, i) => lines.push((i ? ' ' : ' ') + l));
1009
+ }
1010
+ if (notChecked.some((missed) => missed.identity)) {
1011
+ lines.push('');
1012
+ wrap('Those settings are set by your own server, not by a Supabase sign-in. I sign in the way Supabase ' +
1013
+ 'does, so every attack on those tables ran as nobody, and being refused proves nothing about them. ' +
1014
+ 'Check those rules by hand, or test them with your server setting who is asking.', 66)
1015
+ .forEach((l) => lines.push(' ' + l));
920
1016
  }
921
1017
  lines.push('');
922
1018
  lines.push(' These are unknown, not clear. An attack that never ran comes back');
@@ -1196,11 +1292,13 @@ module.exports = {
1196
1292
  privilegedOf: privilegedOf,
1197
1293
  searchPathsOf: searchPathsOf,
1198
1294
  emptyChoicesOf: emptyChoicesOf,
1295
+ noInsertsOf: noInsertsOf,
1199
1296
  oneOfColumns: oneOfColumns,
1200
1297
  conflictTargetsOf: conflictTargetsOf,
1201
1298
  partialWhereFor: partialWhereFor,
1202
1299
  conflictsIn: conflictsIn,
1203
1300
  notCalledOf: notCalledOf,
1301
+ serverIdentityOf: serverIdentityOf,
1204
1302
  bucketsOf: bucketsOf,
1205
1303
  loopingTables: loopingTables,
1206
1304
  taggerFor: taggerFor,
package/schema.js CHANGED
@@ -387,6 +387,11 @@ async function readFunctions(client, schema) {
387
387
  pg_get_functiondef(p.oid) AS def,
388
388
  pg_get_function_identity_arguments(p.oid) AS args,
389
389
  p.proacl IS NULL AS default_acl,
390
+ -- Installed by an extension (pgcrypto's crypt(), digest() ...):
391
+ -- not something the app wrote, so never listed as "your app's
392
+ -- functions" (blind round 13 listed 23 of pgcrypto's).
393
+ EXISTS (SELECT 1 FROM pg_depend d
394
+ WHERE d.classid = 'pg_proc'::regclass AND d.objid = p.oid AND d.deptype = 'e') AS from_extension,
390
395
  -- Whether the app can call it at all, as a visitor or signed in.
391
396
  -- Such a function is something the app does that was read and
392
397
  -- never run - said under "What I did not test" (scan.js notCalledOf).
@@ -449,7 +454,18 @@ const BUCKETS_QUERY =
449
454
  FROM pg_policies p
450
455
  WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
451
456
  AND p.cmd IN ('SELECT', 'ALL')
452
- AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0)
457
+ AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0),
458
+ -- The rules for putting files in and taking them out: who the app
459
+ -- lets write a bucket says who its files are for (scan.js bucketsOf).
460
+ 'writeRules', (SELECT coalesce(jsonb_agg(jsonb_build_object(
461
+ 'name', p.policyname::text,
462
+ 'roles', to_jsonb(p.roles::text[]),
463
+ 'qual', p.qual,
464
+ 'with_check', p.with_check) ORDER BY p.policyname), '[]'::jsonb)
465
+ FROM pg_policies p
466
+ WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
467
+ AND p.cmd IN ('INSERT', 'UPDATE', 'DELETE', 'ALL')
468
+ AND position(quote_literal(b.id::text) IN coalesce(p.qual, '') || ' ' || coalesce(p.with_check, '')) > 0)
453
469
  ) ORDER BY b.id), '[]'::jsonb) AS buckets
454
470
  FROM storage.buckets b`;
455
471
 
package/tamper.js CHANGED
@@ -297,7 +297,10 @@ function roleColumnOf(table) {
297
297
  // the order reads are tried in, so the least trusted one that can read a
298
298
  // secret is the one named.
299
299
  const ladder = [lowest].concat(labels.filter((label) => label !== lowest && !IN_CHARGE.test(label)).reverse());
300
- if (lowest) return { column: column.name, type: column.type, lowest: lowest, ladder: ladder };
300
+ // The role at the top: an owner if there is one, else the first in charge.
301
+ const top = labels.find((label) => /owner/i.test(label)) || labels.find((label) => IN_CHARGE.test(label)) || null;
302
+ const inCharge = labels.filter((label) => IN_CHARGE.test(label));
303
+ if (lowest) return { column: column.name, type: column.type, lowest: lowest, ladder: ladder, top: top, inCharge: inCharge };
301
304
  }
302
305
  return null;
303
306
  }
@@ -334,7 +337,7 @@ function memberships(tables) {
334
337
  .filter((key) => key.refTable !== table.name && names.has(key.refTable) && !key.columns.includes(person));
335
338
  if (!keys.length || aloneUnique(table, person)) continue;
336
339
  found.push({ table: table, person: person, role: role.column, roleType: role.type, lowest: role.lowest,
337
- ladder: role.ladder, keys: keys });
340
+ ladder: role.ladder, top: role.top, inCharge: role.inCharge || [], keys: keys });
338
341
  }
339
342
  // A table named for members first: it is the one a team rule asks.
340
343
  const named = (m) => (/member/i.test(m.table.name) ? 0 : 1);
@@ -343,7 +346,7 @@ function memberships(tables) {
343
346
 
344
347
  // What each kind of write is called in a rule.
345
348
  // A takeover is an UPDATE too: a rule for updating is what it would hide behind.
346
- const COMMAND = { add: 'INSERT', change: 'UPDATE', delete: 'DELETE', takeover: 'UPDATE' };
349
+ const COMMAND = { add: 'INSERT', change: 'UPDATE', delete: 'DELETE', takeover: 'UPDATE', promote: 'UPDATE' };
347
350
 
348
351
  /**
349
352
  * Whether the app's own rules name this role among those allowed to make
@@ -502,6 +505,21 @@ async function teammate(client, schema, tables, seeded, rules) {
502
505
  statement: 'UPDATE ' + target + ' SET ' + quote(member.person) + " = '" + attack.USER_B + "' WHERE " + firstPersons.join(' AND '),
503
506
  values: [],
504
507
  });
508
+ // Their own membership row, given the role at the top. Found on blind
509
+ // round 15 (Northstar Ops on Supabase): memberships_update checked only
510
+ // user_id = auth.uid(), so a viewer set their own role to 'owner'.
511
+ // Before joining they have no row, so only what joining added counts.
512
+ if (member.top) {
513
+ const ownRow = [quote(member.person) + " = '" + attack.USER_B + "'"]
514
+ .concat(Object.keys(pointed).map((name) => quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'"));
515
+ moves.push({
516
+ what: 'promote',
517
+ anyRole: true,
518
+ statement: 'UPDATE ' + target + ' SET ' + quote(member.role) + " = '" + String(member.top).split("'").join("''") +
519
+ "' WHERE " + ownRow.join(' AND '),
520
+ values: [],
521
+ });
522
+ }
505
523
  }
506
524
  // Adding a row to the team, said only of a role whose own name says it
507
525
  // may only look: a member adding rows is the feature, a viewer adding
@@ -591,12 +609,60 @@ async function teammate(client, schema, tables, seeded, rules) {
591
609
  below: below.slice(),
592
610
  columns: (table.columns || []).map((c) => c.name),
593
611
  rlsEnabled: table.rlsEnabled,
594
- }, can.includes('takeover') ? { takeover: true, personColumn: member.person } : {}));
612
+ }, can.includes('takeover') ? { takeover: true, personColumn: member.person } : {},
613
+ can.includes('promote') ? { promote: true, toRole: member.top } : {}));
595
614
  break;
596
615
  }
597
616
  if (!listed) below.push(role);
598
617
  }
599
618
 
619
+ // In charge of their OWN team, reading the first person's. Found on
620
+ // blind round 15 (Northstar Ops on Supabase): projects_select let in
621
+ // anyone for whom `exists (select 1 from memberships m where m.role =
622
+ // 'admin')` - not tied to the project's organization - so an admin of
623
+ // any organization read every organization's projects. The second
624
+ // person's own membership is given each role in charge, one at a time,
625
+ // and the first person's team's rows are asked for; only what the role
626
+ // added counts. A table that names neither the team nor a person is
627
+ // not asked.
628
+ const theirTeam = Object.keys(pointed).length
629
+ ? Object.keys(pointed).map((name) => quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'").join(' AND ')
630
+ : owner ? quote(owner) + " = '" + attack.USER_A + "'" : null;
631
+ if (theirTeam && member.inCharge.length) {
632
+ const look = 'SELECT 1 FROM ' + target + ' WHERE ' + theirTeam;
633
+ const plain = counted(await tryWrite(client, 'authenticated', attack.USER_B, look, []));
634
+ for (const label of member.inCharge) {
635
+ const key = 'elevated:' + table.name + ':' + label;
636
+ const raise = {
637
+ statement: 'UPDATE ' + quote(schema) + '.' + quote(member.table.name) + ' SET ' + quote(member.role) + ' = $1 WHERE ' +
638
+ quote(member.person) + ' = $2',
639
+ values: [label, attack.USER_B],
640
+ };
641
+ const now = counted(await tryWrite(client, 'authenticated', attack.USER_B, look, [], raise));
642
+ if (plain === null || now === null) {
643
+ blocked.push({ table: table.name, key: key, why: 'as "' + label + '" in their own ' + parent + ' row: reading it failed' });
644
+ break;
645
+ }
646
+ completed.push(key);
647
+ if (now > plain) {
648
+ findings.push({
649
+ kind: 'crossed',
650
+ table: table.name,
651
+ owner: owner || Object.keys(pointed).sort()[0],
652
+ tenant: !owner,
653
+ readable: now - plain,
654
+ asRole: label,
655
+ via: member.table.name,
656
+ roleColumn: member.role,
657
+ parent: parent,
658
+ columns: (table.columns || []).map((c) => c.name),
659
+ rlsEnabled: table.rlsEnabled,
660
+ });
661
+ break;
662
+ }
663
+ }
664
+ }
665
+
600
666
  // Reading what a member should not see, said only of a table holding a
601
667
  // secret. Every role not in charge, from the lowest up; the first that
602
668
  // can read is the one named. A member reading their team's ordinary