kryptheon-night 0.1.34 → 0.1.36

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/LICENSE CHANGED
@@ -5,7 +5,7 @@ Copyright (c) 2026 Kryptheon (https://kryptheon.tech). All rights reserved.
5
5
  This software is proprietary. It is not open source.
6
6
 
7
7
  1. Use. You may install and run this software, as published by Kryptheon,
8
- free of charge, to check software and databases that you own or are
8
+ at no charge, to check software and databases that you own or are
9
9
  authorised to test. Features that kryptheon.tech offers on a paid plan
10
10
  are provided only under that plan and the Terms at
11
11
  https://kryptheon.tech/terms.
package/README.md CHANGED
@@ -69,16 +69,24 @@ Four questions, asked against the copy:
69
69
  | **Collision** | can the same thing exist twice? |
70
70
  | **Interruption** | can a half-finished write survive? |
71
71
 
72
- And three things it says are **yours to check**, because only you know
73
- whether they are meant:
72
+ One more, read from how your functions are written: **does an upsert work at
73
+ all?** An `INSERT ... ON CONFLICT` that matches no unique rule fails on every
74
+ call; it is put to Postgres on the copy with `EXPLAIN` (planned, never run).
75
+
76
+ And these it says are **yours to check**, because only you know whether they
77
+ are meant:
74
78
 
75
79
  | check | the question |
76
80
  |---|---|
77
81
  | **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
82
  | **Storage** | is a bucket public although a rule says who may read it? (settings only, never a file) |
79
83
  | **Open functions** | can a visitor with no account call a function that skips the rules? |
84
+ | **Open search_path** | does a function that skips the rules leave its `search_path` unpinned? |
85
+ | **Empty choices** | can a column a `CHECK` keeps to a list (`status IN (...)`) still be left empty? |
80
86
 
81
- These are listed apart from problems and do not change the exit code.
87
+ These are listed apart from problems and do not change the exit code. Your
88
+ app's functions that nothing above calls are named under "What I did not
89
+ test" - read, never run.
82
90
 
83
91
  A problem reads like this:
84
92
 
@@ -247,4 +255,4 @@ the driver for one reaching Postgres over 443.
247
255
 
248
256
  ## Licence
249
257
 
250
- Free to use, not open source: see LICENSE. Paid features are on kryptheon.tech.
258
+ Proprietary software, not open source - see LICENSE. It runs on your own machine, with no account and no card. The nightly check, with its morning report, alerts and history, is part of the Pro and Studio plans on kryptheon.tech.
package/engine.sql CHANGED
@@ -261,6 +261,12 @@ 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
+ -- Whether the app can call it at all (schema.js readFunctions).
265
+ 'client_callable', CASE WHEN EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon')
266
+ AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated')
267
+ THEN has_function_privilege('anon', p.oid, 'EXECUTE')
268
+ OR has_function_privilege('authenticated', p.oid, 'EXECUTE')
269
+ ELSE false END,
264
270
  'executors', (SELECT coalesce(jsonb_agg(g ORDER BY g), '[]'::jsonb)
265
271
  FROM (SELECT DISTINCT CASE WHEN a.grantee = 0 THEN 'PUBLIC'
266
272
  ELSE pg_get_userbyid(a.grantee)::text END AS g
@@ -465,7 +471,9 @@ BEGIN
465
471
  pg_get_functiondef(p.oid) AS def,
466
472
  p.prorettype = 'pg_catalog.bool'::regtype AS returns_bool,
467
473
  (SELECT t.typtype = 'e' FROM pg_type t WHERE t.oid = p.prorettype) AS returns_enum,
468
- CASE WHEN has_role THEN has_function_privilege(role_, p.oid, 'EXECUTE') ELSE false END AS callable
474
+ CASE WHEN has_role THEN has_function_privilege(role_, p.oid, 'EXECUTE') ELSE false END AS callable,
475
+ CASE WHEN EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated')
476
+ THEN has_function_privilege('authenticated', p.oid, 'EXECUTE') ELSE false END AS callable_signed_in
469
477
  FROM pg_proc p
470
478
  JOIN pg_namespace n ON n.oid = p.pronamespace
471
479
  WHERE n.nspname = source
@@ -485,7 +493,8 @@ BEGIN
485
493
  'guarded', (door->>'guarded')::boolean,
486
494
  'nullSlips', (door->>'nullSlips')::boolean,
487
495
  'hasFixedSearchPath', conf ~* '(^|,)search_path=',
488
- 'callable', coalesce(r.callable, false)));
496
+ 'callable', coalesce(r.callable, false),
497
+ 'callableSignedIn', coalesce(r.callable_signed_in, false)));
489
498
  END LOOP;
490
499
  RETURN out_;
491
500
  END $$;
@@ -3804,6 +3813,163 @@ BEGIN
3804
3813
  RETURN out;
3805
3814
  END $$;
3806
3815
 
3816
+ -- --------------------------------------------------------------------------
3817
+ -- How the app's functions and columns are declared (scan.js searchPathsOf,
3818
+ -- emptyChoicesOf, conflictTargetsOf, conflictsIn, notCalledOf), found on a
3819
+ -- blind test (Northstar). Read from the shape; the one thing put to Postgres
3820
+ -- is EXPLAIN on the empty copy, which plans a statement and never runs it.
3821
+ -- --------------------------------------------------------------------------
3822
+
3823
+ /* Definer functions the app can call that leave their search_path open, and
3824
+ are not already reported as privileged (which says it as part of that). */
3825
+ CREATE OR REPLACE FUNCTION __KN__.search_path_findings(plan jsonb)
3826
+ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
3827
+ SELECT coalesce(jsonb_agg(jsonb_build_object(
3828
+ 'kind', 'searchpath', 'fn', f->>'name', 'table', f->>'name', 'args', f->>'args',
3829
+ 'writes', coalesce((f->>'writes')::boolean, false), 'anon', coalesce((f->>'callable')::boolean, false),
3830
+ 'columns', '[]'::jsonb) ORDER BY ord), '[]'::jsonb)
3831
+ FROM jsonb_array_elements(coalesce(plan->'anonFunctions', '[]'::jsonb)) WITH ORDINALITY AS a(f, ord)
3832
+ WHERE (coalesce((f->>'callable')::boolean, false) OR coalesce((f->>'callableSignedIn')::boolean, false))
3833
+ AND NOT coalesce((f->>'hasFixedSearchPath')::boolean, false)
3834
+ AND NOT (coalesce((f->>'callable')::boolean, false) AND NOT coalesce((f->>'aboutCaller')::boolean, false)
3835
+ AND NOT coalesce((f->>'guarded')::boolean, false));
3836
+ $$;
3837
+
3838
+ /* Every column a CHECK keeps to a fixed list (scan.js ONE_OF, oneOfColumns). */
3839
+ CREATE OR REPLACE FUNCTION __KN__.one_of_columns(plan jsonb)
3840
+ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
3841
+ SELECT coalesce(jsonb_agg(jsonb_build_object(
3842
+ 'table', t->>'name', 'column', cc.col, 'constraint', k->>'name', 'definition', k->>'definition')
3843
+ ORDER BY ti, ki), '[]'::jsonb)
3844
+ FROM jsonb_array_elements(coalesce(plan->'tables', '[]'::jsonb)) WITH ORDINALITY AS a(t, ti)
3845
+ CROSS JOIN LATERAL jsonb_array_elements(coalesce(t->'constraints', '[]'::jsonb)) WITH ORDINALITY AS b(k, ki)
3846
+ CROSS JOIN LATERAL (SELECT (regexp_match(coalesce(k->>'definition', ''),
3847
+ '^CHECK \(+"?([A-Za-z_][\w$]*)"?\)?(?:::[\w ]+)? = ANY \('))[1] AS name) m
3848
+ CROSS JOIN LATERAL (SELECT c FROM jsonb_array_elements(coalesce(t->'columns', '[]'::jsonb)) c
3849
+ WHERE c->>'name' = m.name LIMIT 1) cc(col)
3850
+ WHERE k->>'kind' = 'c'
3851
+ AND NOT coalesce(k->>'definition', '') ~* '\y(or|null|and)\y'
3852
+ AND m.name IS NOT NULL;
3853
+ $$;
3854
+
3855
+ /* The ones of those that still take an empty value. */
3856
+ CREATE OR REPLACE FUNCTION __KN__.empty_choice_findings(plan jsonb)
3857
+ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
3858
+ SELECT coalesce(jsonb_agg(jsonb_build_object(
3859
+ 'kind', 'nullcheck', 'table', x->>'table', 'column', x->'column'->>'name',
3860
+ 'allowed', (SELECT coalesce(jsonb_agg(replace(v[1], '''''', '''') ORDER BY o), '[]'::jsonb)
3861
+ FROM regexp_matches(x->>'definition', '''((?:[^'']|'''')*)''', 'g') WITH ORDINALITY AS r(v, o)),
3862
+ 'constraint', x->>'constraint', 'columns', '[]'::jsonb) ORDER BY n), '[]'::jsonb)
3863
+ FROM jsonb_array_elements(__KN__.one_of_columns(plan)) WITH ORDINALITY AS a(x, n)
3864
+ WHERE NOT coalesce((x->'column'->>'not_null')::boolean, false)
3865
+ AND x->'column'->>'default_expr' IS NULL
3866
+ AND x->'column'->>'generated' IS NULL;
3867
+ $$;
3868
+
3869
+ /* Every INSERT ... ON CONFLICT (...) in the app's functions (scan.js
3870
+ conflictTargetsOf, step for step). */
3871
+ CREATE OR REPLACE FUNCTION __KN__.conflict_targets(plan jsonb)
3872
+ RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
3873
+ DECLARE
3874
+ ws constant text := E' \t\r\n';
3875
+ tables jsonb := coalesce((SELECT jsonb_agg(t->>'name') FROM jsonb_array_elements(coalesce(plan->'tables', '[]'::jsonb)) t), '[]'::jsonb);
3876
+ fn jsonb;
3877
+ body text;
3878
+ stmt text;
3879
+ into_ text[];
3880
+ on_ text[];
3881
+ parts text[];
3882
+ target text;
3883
+ where_ text;
3884
+ key text;
3885
+ seen text[] := '{}';
3886
+ out_ jsonb := '[]'::jsonb;
3887
+ BEGIN
3888
+ FOR fn IN SELECT * FROM jsonb_array_elements(coalesce(plan->'functions', '[]'::jsonb)) LOOP
3889
+ body := regexp_replace(coalesce(fn->>'src', ''), '--[^\n]*', ' ', 'g');
3890
+ FOREACH stmt IN ARRAY string_to_array(body, ';') LOOP
3891
+ into_ := regexp_match(stmt,
3892
+ '(?:^|[^\w$])insert\s+into\s+((?:"[^"]+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"[^"]+"|[A-Za-z_][\w$]*))?)', 'i');
3893
+ CONTINUE WHEN into_ IS NULL;
3894
+ on_ := regexp_match(stmt, '(?:^|[^\w$])on\s+conflict\s*\(([^)]*)\)(.*)$', 'i');
3895
+ CONTINUE WHEN on_ IS NULL OR btrim(on_[1], ws) = '';
3896
+ SELECT array_agg(CASE WHEN m[1] LIKE '"%' THEN substr(m[1], 2, length(m[1]) - 2) ELSE lower(m[1]) END ORDER BY o)
3897
+ INTO parts
3898
+ FROM regexp_matches(into_[1], '("[^"]+"|[A-Za-z_][\w$]*)', 'g') WITH ORDINALITY AS r(m, o);
3899
+ CONTINUE WHEN array_length(parts, 1) = 2 AND parts[1] IS DISTINCT FROM plan->>'schema';
3900
+ target := parts[array_length(parts, 1)];
3901
+ CONTINUE WHEN NOT tables ? target;
3902
+ where_ := CASE WHEN on_[2] ~* '^\s*where\y'
3903
+ THEN btrim((regexp_split_to_array(on_[2], '\ydo\y', 'i'))[1], ws) ELSE '' END;
3904
+ key := concat_ws('|', fn->>'name', target, btrim(on_[1], ws), where_);
3905
+ CONTINUE WHEN key = ANY (seen);
3906
+ seen := seen || key;
3907
+ out_ := out_ || jsonb_build_array(jsonb_build_object(
3908
+ 'fn', fn->>'name', 'args', coalesce(fn->>'args', ''), 'target', target,
3909
+ 'on', btrim(on_[1], ws), 'where', where_));
3910
+ END LOOP;
3911
+ END LOOP;
3912
+ RETURN out_;
3913
+ END $$;
3914
+
3915
+ /* The WHERE of a partial unique index on exactly these columns (scan.js
3916
+ partialWhereFor). */
3917
+ CREATE OR REPLACE FUNCTION __KN__.partial_where_for(indexes jsonb, tab text, on_ text)
3918
+ RETURNS text LANGUAGE sql IMMUTABLE AS $$
3919
+ SELECT x.m[2]
3920
+ FROM jsonb_array_elements(coalesce(indexes, '[]'::jsonb)) WITH ORDINALITY AS a(i, o)
3921
+ CROSS JOIN LATERAL (SELECT regexp_match(coalesce(i->>'definition', ''), 'USING \w+ \(([^)]*)\)(?: WHERE (.*))?$') AS m) x
3922
+ WHERE i->>'table_name' = tab AND x.m IS NOT NULL AND x.m[2] IS NOT NULL
3923
+ AND lower(regexp_replace(x.m[1], '["\s]', '', 'g')) = lower(regexp_replace(on_, '["\s]', '', 'g'))
3924
+ ORDER BY o
3925
+ LIMIT 1;
3926
+ $$;
3927
+
3928
+ /* The upserts Postgres refuses, asked of the copy with EXPLAIN - planned,
3929
+ never run; only "no unique or exclusion constraint matching" (42P10)
3930
+ counts (scan.js conflictsIn). */
3931
+ CREATE OR REPLACE FUNCTION __KN__.conflicts(into_ text, plan jsonb, indexes jsonb)
3932
+ RETURNS jsonb LANGUAGE plpgsql AS $$
3933
+ DECLARE
3934
+ t jsonb;
3935
+ refused boolean;
3936
+ out_ jsonb := '[]'::jsonb;
3937
+ BEGIN
3938
+ FOR t IN SELECT * FROM jsonb_array_elements(__KN__.conflict_targets(plan)) LOOP
3939
+ refused := false;
3940
+ BEGIN
3941
+ EXECUTE 'EXPLAIN INSERT INTO ' || quote_ident(into_) || '.' || quote_ident(t->>'target') ||
3942
+ ' DEFAULT VALUES ON CONFLICT (' || (t->>'on') || ')' ||
3943
+ CASE WHEN t->>'where' <> '' THEN ' ' || (t->>'where') ELSE '' END || ' DO NOTHING';
3944
+ EXCEPTION
3945
+ WHEN invalid_column_reference THEN refused := true;
3946
+ WHEN OTHERS THEN NULL;
3947
+ END;
3948
+ CONTINUE WHEN NOT refused;
3949
+ out_ := out_ || jsonb_build_array(jsonb_build_object(
3950
+ 'kind', 'conflict', 'fn', t->>'fn', 'table', t->>'fn', 'args', t->>'args',
3951
+ 'target', t->>'target', 'on', t->>'on',
3952
+ 'partialWhere', __KN__.partial_where_for(indexes, t->>'target', t->>'on'), 'columns', '[]'::jsonb));
3953
+ END LOOP;
3954
+ RETURN out_;
3955
+ END $$;
3956
+
3957
+ /* The app's functions read and never called (scan.js notCalledOf). */
3958
+ CREATE OR REPLACE FUNCTION __KN__.not_called(plan jsonb, findings jsonb)
3959
+ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
3960
+ WITH rules AS (
3961
+ SELECT coalesce(string_agg(coalesce(p->>'qual', '') || ' ' || coalesce(p->>'with_check', ''), ' '), '') AS text_
3962
+ FROM jsonb_array_elements(coalesce(plan->'policies', '[]'::jsonb)) p
3963
+ ), named AS (
3964
+ SELECT f->>'fn' AS name FROM jsonb_array_elements(coalesce(findings, '[]'::jsonb)) f WHERE f->>'fn' IS NOT NULL
3965
+ )
3966
+ SELECT coalesce(jsonb_agg(DISTINCT (fn->>'name') COLLATE "C" ORDER BY (fn->>'name') COLLATE "C"), '[]'::jsonb)
3967
+ FROM jsonb_array_elements(coalesce(plan->'functions', '[]'::jsonb)) fn, rules
3968
+ WHERE coalesce((fn->>'client_callable')::boolean, false)
3969
+ AND (fn->>'name') NOT IN (SELECT name FROM named)
3970
+ AND NOT __KN__.calls_function(rules.text_, fn->>'name');
3971
+ $$;
3972
+
3807
3973
  /* Where the nightly answers are kept, for `kryptheon night` to read back. */
3808
3974
  CREATE TABLE IF NOT EXISTS __KN__.runs (
3809
3975
  id bigserial PRIMARY KEY,
@@ -3814,6 +3980,8 @@ CREATE TABLE IF NOT EXISTS __KN__.runs (
3814
3980
  findings jsonb NOT NULL DEFAULT '[]'::jsonb,
3815
3981
  not_checked jsonb NOT NULL DEFAULT '[]'::jsonb
3816
3982
  );
3983
+ -- Added for an install made before it: the app's functions read and never called.
3984
+ ALTER TABLE __KN__.runs ADD COLUMN IF NOT EXISTS not_called jsonb NOT NULL DEFAULT '[]'::jsonb;
3817
3985
 
3818
3986
  /*
3819
3987
  * One night's work, and the id of the row it wrote.
@@ -3838,6 +4006,7 @@ DECLARE
3838
4006
  ranked jsonb;
3839
4007
  stranded jsonb;
3840
4008
  raced jsonb;
4009
+ refused jsonb;
3841
4010
  findings jsonb := '[]'::jsonb;
3842
4011
  not_checked jsonb := '[]'::jsonb;
3843
4012
  attempted jsonb := '[]'::jsonb;
@@ -3873,6 +4042,10 @@ BEGIN
3873
4042
  FROM jsonb_array_elements(copy_plan->'tables') t
3874
4043
  WHERE NOT (stand_ins ? (t->>'name'));
3875
4044
 
4045
+ -- Upserts Postgres would refuse, asked of the empty copy before anything
4046
+ -- is put in it (scan.js, the same place).
4047
+ refused := __KN__.conflicts(copy_name, plan, copy_plan->'indexes');
4048
+
3876
4049
  sown := __KN__.seed(copy_name, theirs);
3877
4050
 
3878
4051
  -- Views have no rows of their own and are never seeded, which is exactly
@@ -3922,6 +4095,9 @@ BEGIN
3922
4095
  FROM jsonb_array_elements(coalesce(plan->'anonFunctions', '[]'::jsonb)) f
3923
4096
  WHERE (f->>'callable')::boolean AND NOT (f->>'aboutCaller')::boolean AND NOT (f->>'guarded')::boolean),
3924
4097
  '[]'::jsonb)
4098
+ || __KN__.search_path_findings(plan)
4099
+ || refused
4100
+ || __KN__.empty_choice_findings(plan)
3925
4101
  || __KN__.bucket_findings(plan->'buckets');
3926
4102
 
3927
4103
  -- Everything genuinely attacked, so a re-check can tell a finding that was
@@ -3934,7 +4110,13 @@ BEGIN
3934
4110
  || coalesce((SELECT jsonb_agg('privileged:' || (f->>'name'))
3935
4111
  FROM jsonb_array_elements(coalesce(plan->'anonFunctions', '[]'::jsonb)) f), '[]'::jsonb)
3936
4112
  || coalesce((SELECT jsonb_agg('bucket:' || (b->>'name'))
3937
- FROM jsonb_array_elements(coalesce(plan->'buckets', '[]'::jsonb)) b), '[]'::jsonb);
4113
+ FROM jsonb_array_elements(coalesce(plan->'buckets', '[]'::jsonb)) b), '[]'::jsonb)
4114
+ || coalesce((SELECT jsonb_agg('searchpath:' || (f->>'name'))
4115
+ FROM jsonb_array_elements(coalesce(plan->'anonFunctions', '[]'::jsonb)) f), '[]'::jsonb)
4116
+ || coalesce((SELECT jsonb_agg('conflict:' || (f->>'name'))
4117
+ FROM jsonb_array_elements(coalesce(plan->'functions', '[]'::jsonb)) f), '[]'::jsonb)
4118
+ || coalesce((SELECT jsonb_agg('nullcheck:' || (x->>'table') || ':' || (x->'column'->>'name'))
4119
+ FROM jsonb_array_elements(__KN__.one_of_columns(plan)) x), '[]'::jsonb);
3938
4120
 
3939
4121
  -- And everything that was not. Said out loud, never left out: a table
3940
4122
  -- nothing could be put into reads exactly like a table nothing got out of.
@@ -3945,8 +4127,8 @@ BEGIN
3945
4127
  || coalesce(stranded->'notTried', '[]'::jsonb)
3946
4128
  || coalesce(raced->'notTried', '[]'::jsonb);
3947
4129
 
3948
- INSERT INTO __KN__.runs (source, attacks_run, findings, not_checked)
3949
- VALUES (source, jsonb_array_length(attempted), findings, not_checked)
4130
+ INSERT INTO __KN__.runs (source, attacks_run, findings, not_checked, not_called)
4131
+ VALUES (source, jsonb_array_length(attempted), findings, not_checked, __KN__.not_called(plan, findings))
3950
4132
  RETURNING id INTO run_id;
3951
4133
 
3952
4134
  EXCEPTION WHEN OTHERS THEN
package/finding.js CHANGED
@@ -131,6 +131,12 @@ function severityOf(finding, contents) {
131
131
  // or a bucket may be public, is the owner's decision. So never CRITICAL.
132
132
  return 'HIGH';
133
133
  }
134
+ if (finding.kind === 'conflict' || finding.kind === 'searchpath' || finding.kind === 'nullcheck') {
135
+ // Read from how a function or a column is declared. A refused upsert
136
+ // breaks the app for whoever calls it; an open search_path and an empty
137
+ // value are for the owner to weigh. None of them hands a row to anybody.
138
+ return 'HIGH';
139
+ }
134
140
  if (finding.kind === 'privileged') {
135
141
  // Not proven, so never CRITICAL: this is a reach that exists, not a break
136
142
  // that was demonstrated. A function anon can call that also writes is worth
@@ -358,6 +364,15 @@ function headlineFor(finding) {
358
364
  if (finding.kind === 'privileged') {
359
365
  return 'Anyone on the internet can call your ' + finding.fn + ' function, and it runs with full rights.';
360
366
  }
367
+ if (finding.kind === 'searchpath') {
368
+ return 'Your ' + finding.fn + ' function runs with full rights but does not pin its search_path.';
369
+ }
370
+ if (finding.kind === 'conflict') {
371
+ return 'Your ' + finding.fn + ' function fails every time it saves: Postgres refuses its ON CONFLICT.';
372
+ }
373
+ if (finding.kind === 'nullcheck') {
374
+ return 'Your ' + finding.table + ' table accepts rows with no ' + finding.column + ' at all.';
375
+ }
361
376
  if (finding.kind === 'role' && finding.takeover) {
362
377
  return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can take another member\'s place in your ' +
363
378
  finding.table + ' table - and with it their role.';
@@ -537,6 +552,41 @@ function bodyFor(finding, contents) {
537
552
  );
538
553
  }
539
554
 
555
+ if (finding.kind === 'searchpath') {
556
+ return (
557
+ 'This function is SECURITY DEFINER, so it runs as whoever created it and the row level ' +
558
+ 'security on the tables it touches is skipped for the caller - and ' +
559
+ (finding.anon ? 'anyone, even without an account,' : 'every signed-in user') + ' can call it. ' +
560
+ 'It does not set its search_path, so every table or function inside it written without a ' +
561
+ 'schema (scans rather than public.scans) is looked up wherever the caller\'s path points ' +
562
+ 'first. Somebody able to put a table or function earlier on that path would make it use ' +
563
+ 'theirs, with the owner\'s rights. I did not call it; this is read from how it is declared.'
564
+ );
565
+ }
566
+ if (finding.kind === 'conflict') {
567
+ return (
568
+ finding.fn + ' inserts into ' + finding.target + ' with ON CONFLICT (' + finding.on + '). ' +
569
+ 'Postgres needs a unique index or constraint on exactly those columns to know what a ' +
570
+ 'conflict is, and ' + finding.target + ' has none it can use, so it refuses the statement - ' +
571
+ 'the function errors on every call instead of saving. ' +
572
+ (finding.partialWhere
573
+ ? 'There is a unique index on those columns, but it is partial (WHERE ' + finding.partialWhere +
574
+ '), and a partial index is only used when the ON CONFLICT repeats its WHERE. '
575
+ : '') +
576
+ 'I did not call the function: I asked Postgres to plan that one statement on the copy, and ' +
577
+ 'it answered "there is no unique or exclusion constraint matching the ON CONFLICT specification".'
578
+ );
579
+ }
580
+ if (finding.kind === 'nullcheck') {
581
+ return (
582
+ finding.column + ' has a CHECK that allows only ' +
583
+ listOf((finding.allowed || []).map((v) => '\'' + v + '\'')) + ' - but a CHECK only refuses a ' +
584
+ 'value it can show is wrong, and an empty value (NULL) is never shown wrong, so it passes. ' +
585
+ 'The column is not NOT NULL and has no default, so a row saved without a ' + finding.column +
586
+ ' is kept with none, and anything that shows or sorts by it has to cope. I did not insert ' +
587
+ 'anything; this is read from how the column is declared.'
588
+ );
589
+ }
540
590
  if (finding.kind === 'privileged') {
541
591
  // Said as what is true, not as what was done - nothing was executed. The
542
592
  // reach is real; whether it is a mistake is the person's to say, so the
@@ -917,6 +967,58 @@ function fixPromptFor(finding) {
917
967
  '',
918
968
  'Then look at every other bucket for the same thing.',
919
969
  ]
970
+ : finding.kind === 'searchpath'
971
+ ? [
972
+ 'My app may have a security weakness - please check it rather than assume it.',
973
+ '',
974
+ 'The database function "' + finding.fn + '(' + (finding.args || '') + ')" is SECURITY DEFINER ' +
975
+ '(it runs with its owner\'s rights) and does not set a search_path, so names inside it ' +
976
+ 'without a schema are looked up along the caller\'s path.',
977
+ '',
978
+ 'Fix only this: add SET search_path = \'\' to the function\'s definition, and write every ' +
979
+ 'table, view and function it uses with its schema - public.scans, not scans; auth.uid() ' +
980
+ 'stays as it is. Keep its body, its checks and its grants exactly as they are.',
981
+ '',
982
+ 'Then call it once the way the app does, to make sure it still works, and look at every ' +
983
+ 'other SECURITY DEFINER function for the same thing.',
984
+ ]
985
+ : finding.kind === 'conflict'
986
+ ? [
987
+ 'My app has a broken database function.',
988
+ '',
989
+ 'The function "' + finding.fn + '(' + (finding.args || '') + ')" runs INSERT INTO ' +
990
+ finding.target + ' ... ON CONFLICT (' + finding.on + '), and Postgres refuses it with ' +
991
+ '"there is no unique or exclusion constraint matching the ON CONFLICT specification" - so ' +
992
+ 'it fails on every call.',
993
+ '',
994
+ finding.partialWhere
995
+ ? 'The unique index on (' + finding.on + ') is partial: WHERE ' + finding.partialWhere + '. ' +
996
+ 'Repeat that condition in the function right after the conflict columns: ON CONFLICT (' +
997
+ finding.on + ') WHERE ' + finding.partialWhere + ' DO ... - and keep the rest of the ' +
998
+ 'statement as it is. Do not drop the index\'s WHERE: it is what lets rows without a value exist.'
999
+ : 'Make the ON CONFLICT match a unique rule that exists: either add a unique constraint on ' +
1000
+ finding.target + ' (' + finding.on + ') if those columns really must be unique together, or ' +
1001
+ 'change the conflict columns to an existing unique key. Tell me which you chose and why.',
1002
+ '',
1003
+ 'Then call the function twice with the same values: the second call must update the row, ' +
1004
+ 'not error and not add a second one.',
1005
+ ]
1006
+ : finding.kind === 'nullcheck'
1007
+ ? [
1008
+ 'My app may have a data problem - please check it rather than assume it.',
1009
+ '',
1010
+ 'In the "' + finding.table + '" table, the column "' + finding.column + '" has a CHECK that ' +
1011
+ 'allows only ' + listOf((finding.allowed || []).map((v) => '\'' + v + '\'')) + ', but the column ' +
1012
+ 'can be empty (NULL), and a CHECK lets NULL through.',
1013
+ '',
1014
+ 'First tell me: should every row have a ' + finding.column + '? If an empty one is meant to be ' +
1015
+ 'allowed, change nothing and say so.',
1016
+ '',
1017
+ 'If every row should have one: set the empty ones first (UPDATE "' + finding.table + '" SET "' +
1018
+ finding.column + '" = <the right value> WHERE "' + finding.column + '" IS NULL - ask me what ' +
1019
+ 'the right value is), then ALTER TABLE "' + finding.table + '" ALTER COLUMN "' + finding.column +
1020
+ '" SET NOT NULL. Change nothing else.',
1021
+ ]
920
1022
  : finding.kind === 'privileged'
921
1023
  ? [
922
1024
  'My app may have a security problem - please check it rather than assume it.',
@@ -927,9 +1029,16 @@ function fixPromptFor(finding) {
927
1029
  (finding.writes ? ', and it writes to tables.' : '.'),
928
1030
  '',
929
1031
  'First tell me: is it meant to be callable by logged-out visitors? If it is not, ' +
930
- 'REVOKE EXECUTE ON FUNCTION "' + finding.fn + '"(' + (finding.args || '') + ') FROM anon, PUBLIC, ' +
931
- 'and GRANT EXECUTE only to the role that should run it - usually service_role, the ' +
932
- 'backend key, the way an admin-only function like this should be reached.',
1032
+ 'REVOKE EXECUTE ON FUNCTION "' + finding.fn + '"(' + (finding.args || '') + ') FROM anon, PUBLIC.',
1033
+ '',
1034
+ // Found on a blind test (Northstar): get_recent_findings(org id) was
1035
+ // the app's own RPC for signed-in members, and "grant it to
1036
+ // service_role" would have broken the page that calls it.
1037
+ 'Then decide who should run it. If signed-in users of the app call it, keep it granted to ' +
1038
+ 'authenticated and add a check at its top that the caller - auth.uid() - may act on what ' +
1039
+ 'they passed in, for example that they are a member (or an admin) of the organization ' +
1040
+ 'whose id it takes, and RAISE an exception otherwise. Only if nothing but your own server ' +
1041
+ 'calls it, grant it to service_role alone.',
933
1042
  '',
934
1043
  ].concat(
935
1044
  finding.nullSlips
@@ -1100,7 +1209,9 @@ function fixPromptFor(finding) {
1100
1209
 
1101
1210
  // Found and said, but never claimed as a break: the owner decides. Counted
1102
1211
  // apart in the report, never towards the exit code or a clean re-check.
1103
- const TO_CHECK = ['privileged', 'role', 'teamread', 'bucket'];
1212
+ const TO_CHECK = ['privileged', 'role', 'teamread', 'bucket', 'searchpath', 'nullcheck'];
1213
+ // Functions, said by their name rather than a table's.
1214
+ const ON_A_FUNCTION = ['privileged', 'searchpath', 'conflict'];
1104
1215
 
1105
1216
  /** Everything the report needs about one thing the attack found. */
1106
1217
  function describe(finding) {
@@ -1113,7 +1224,7 @@ function describe(finding) {
1113
1224
  // and the re-check both read this: a "verification required" finding is
1114
1225
  // never counted towards a clean re-check on its own.
1115
1226
  status: TO_CHECK.includes(finding.kind) ? 'verification required' : 'confirmed',
1116
- table: finding.kind === 'privileged' ? finding.fn : finding.table,
1227
+ table: ON_A_FUNCTION.includes(finding.kind) ? finding.fn : finding.table,
1117
1228
  fn: finding.fn,
1118
1229
  args: finding.args,
1119
1230
  writes: finding.writes,
@@ -1131,7 +1242,7 @@ function describe(finding) {
1131
1242
  // For a privileged function there is no "why the door is open" the way a
1132
1243
  // table has one; the body already carries the whole explanation, so the
1133
1244
  // cause line would only repeat it. Left empty and skipped by the report.
1134
- cause: TO_CHECK.includes(finding.kind) ? '' : cause.long,
1245
+ cause: TO_CHECK.includes(finding.kind) || finding.kind === 'conflict' ? '' : cause.long,
1135
1246
  who: finding.who,
1136
1247
  can: finding.can,
1137
1248
  via: finding.via,
@@ -1147,7 +1258,21 @@ function describe(finding) {
1147
1258
  personColumn: finding.personColumn,
1148
1259
  nullSlips: finding.nullSlips,
1149
1260
  noRule: finding.noRule,
1150
- proof: finding.kind === 'recursive'
1261
+ // Read from how a function or column is declared (scan.js searchPathsOf,
1262
+ // conflictsIn, emptyChoicesOf); kept so the read-back says it the same way.
1263
+ target: finding.target,
1264
+ on: finding.on,
1265
+ partialWhere: finding.partialWhere,
1266
+ allowed: finding.allowed,
1267
+ anon: finding.anon,
1268
+ proof: finding.kind === 'searchpath'
1269
+ ? 'I did not call "' + finding.fn + '". I read that it is SECURITY DEFINER with no search_path set.'
1270
+ : finding.kind === 'conflict'
1271
+ ? 'I asked Postgres to plan its INSERT INTO ' + finding.target + ' ... ON CONFLICT (' + finding.on +
1272
+ ') on the copy, and it refused. I did not call the function.'
1273
+ : finding.kind === 'nullcheck'
1274
+ ? 'I read that "' + finding.table + '.' + finding.column + '" may be NULL and has no default. I did not insert anything.'
1275
+ : finding.kind === 'recursive'
1151
1276
  ? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
1152
1277
  loopError(finding) + '.'
1153
1278
  : finding.kind === 'privileged'
@@ -1273,7 +1398,7 @@ function describeAll(findings) {
1273
1398
  // Writes above reads: a table somebody emptied is worse than one they read.
1274
1399
  // A looping rule sits with the reads: it breaks the app for everyone it
1275
1400
  // fails for, which matters more than a duplicate or a stray row.
1276
- const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, duplicated: 3, orphaned: 4, role: 4.5, teamread: 4.6, bucket: 4.7, privileged: 5 };
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 };
1277
1402
  const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
1278
1403
  return described.sort((a, b) => rank(a) - rank(b));
1279
1404
  }
package/installer.js CHANGED
@@ -526,8 +526,10 @@ async function latestRun(client, options) {
526
526
  const where = await status(client, options);
527
527
  if (!where) return { installed: false, where: null, run: null };
528
528
  const { rows } = await client.query(
529
- 'SELECT ran_at, source, stopped, attacks_run, findings, not_checked FROM ' +
530
- quote(where.schema) + '.runs ORDER BY ran_at DESC, id DESC LIMIT 1',
529
+ // not_called through to_jsonb: an install older than 0.1.35 has no such
530
+ // column, and naming it would fail the whole read-back.
531
+ "SELECT ran_at, source, stopped, attacks_run, findings, not_checked, coalesce(to_jsonb(r) -> 'not_called', '[]') AS not_called FROM " +
532
+ quote(where.schema) + '.runs r ORDER BY ran_at DESC, id DESC LIMIT 1',
531
533
  );
532
534
  return { installed: true, where: where, run: rows[0] || null };
533
535
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon-night",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
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",
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",
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",
package/recheck.js CHANGED
@@ -229,6 +229,9 @@ function whatOf(item) {
229
229
  if (item.kind === 'teamread') return 'read by a "' + (item.who || 'viewer') + '"';
230
230
  if (item.kind === 'bucket') return 'a public storage bucket';
231
231
  if (item.kind === 'recursive') return 'a rule that refers to itself';
232
+ if (item.kind === 'searchpath') return 'full rights, search_path not pinned';
233
+ if (item.kind === 'conflict') return 'an upsert Postgres refuses';
234
+ if (item.kind === 'nullcheck') return 'a ' + (item.column || 'value') + ' that can be left empty';
232
235
  return item.kind;
233
236
  }
234
237
 
@@ -307,6 +310,13 @@ function describe(result) {
307
310
  : 'I looked again, and a visitor with no account can no longer call it.')
308
311
  : item.kind === 'bucket'
309
312
  ? (elsewhere ? 'I looked here, and the bucket is not public.' : 'I looked again, and the bucket is no longer public.')
313
+ : item.kind === 'searchpath'
314
+ ? (elsewhere ? 'I looked here, and it pins its search_path.' : 'I looked again, and it now pins its search_path.')
315
+ : item.kind === 'conflict'
316
+ ? (elsewhere ? 'I asked Postgres to plan it here, and it accepted it.'
317
+ : 'I asked Postgres to plan it again, and it accepted it.')
318
+ : item.kind === 'nullcheck'
319
+ ? (elsewhere ? 'I looked here, and it cannot be left empty.' : 'I looked again, and it can no longer be left empty.')
310
320
  : item.kind === 'recursive'
311
321
  ? (elsewhere ? 'I read it here, and its rules answered.'
312
322
  : 'I read it again, and its rules answered instead of stopping with an error.')
package/scan.js CHANGED
@@ -154,6 +154,7 @@ function nodeEngine() {
154
154
  tamper.tamper(client, into, tables, seeded, policies),
155
155
  orphan: (client, into, tables, seeded) => orphan.orphan(client, into, tables, seeded),
156
156
  teammate: (client, into, tables, seeded, rules) => tamper.teammate(client, into, tables, seeded, rules),
157
+ conflicts: (client, into, plan, indexes) => conflictsIn(client, into, plan, indexes),
157
158
  };
158
159
  }
159
160
 
@@ -230,6 +231,10 @@ async function scan(client, sourceSchema, options) {
230
231
  };
231
232
  }
232
233
 
234
+ // Upserts Postgres would refuse, asked of the empty copy before anything
235
+ // is put in it - planned, never run (conflictsIn).
236
+ const refused = engine.conflicts ? await engine.conflicts(client, copyName, plan, copyPlan.indexes) : [];
237
+
233
238
  say(' Copy matches. Seeding two people and attacking it ...');
234
239
  // The stand-ins built for tables outside the schema belong to this tool,
235
240
  // not to the customer. Attacking them would produce findings about a table
@@ -407,6 +412,15 @@ async function scan(client, sourceSchema, options) {
407
412
  for (const fn of plan.anonFunctions || []) attempted.push('privileged:' + fn.name);
408
413
  const privileged = privilegedOf(plan);
409
414
 
415
+ // How the app's functions and columns are declared: an open search_path,
416
+ // a list a CHECK keeps that still takes an empty value. Read from the
417
+ // shape. Every function and column looked at counts as examined, so a fix
418
+ // comes back from a re-check as fixed.
419
+ for (const fn of plan.anonFunctions || []) attempted.push('searchpath:' + fn.name);
420
+ for (const fn of plan.functions || []) attempted.push('conflict:' + fn.name);
421
+ for (const x of oneOfColumns(plan)) attempted.push('nullcheck:' + x.table + ':' + x.column.name);
422
+ const declared = searchPathsOf(plan).concat(refused, emptyChoicesOf(plan));
423
+
410
424
  // Storage buckets switched to public that also have a rule saying who may
411
425
  // read them. Read from the bucket settings and the rules, never a file.
412
426
  // Every bucket read counts as examined, so one made private comes back
@@ -425,8 +439,13 @@ async function scan(client, sourceSchema, options) {
425
439
  'SELECT current_database() AS database, host(inet_server_addr()) AS host, inet_server_port() AS port',
426
440
  )).rows[0];
427
441
 
442
+ const everything = impersonation.findings.concat(writes.findings, ranked.findings, stranded.findings, raced,
443
+ privileged, declared, buckets, recursive);
444
+
428
445
  return {
429
446
  stopped: null,
447
+ // The app's functions read and never run - named under "What I did not test".
448
+ notCalled: notCalledOf(plan, everything),
430
449
  where: {
431
450
  database: at.database,
432
451
  host: at.host || null,
@@ -437,9 +456,7 @@ async function scan(client, sourceSchema, options) {
437
456
  attacksRun: attempted.length,
438
457
  notChecked: notChecked,
439
458
  attempted: attempted,
440
- findings: finding.describeAll(
441
- impersonation.findings.concat(writes.findings, ranked.findings, stranded.findings, raced, privileged, buckets, recursive).map(tagged),
442
- ),
459
+ findings: finding.describeAll(everything.map(tagged)),
443
460
  };
444
461
  } finally {
445
462
  try {
@@ -511,6 +528,191 @@ function privilegedOf(plan) {
511
528
  }));
512
529
  }
513
530
 
531
+ /**
532
+ * Definer functions the app can call that leave their search_path open.
533
+ *
534
+ * Found on a blind test (Northstar): record_scan was SECURITY DEFINER,
535
+ * callable by every signed-in user, guarded at the door - and it said
536
+ * `insert into scans` with no schema and no search_path set. A name without a
537
+ * schema is looked up along whatever path the session has, so anybody able to
538
+ * put a table or function earlier on that path makes the function use theirs,
539
+ * with the owner's rights. Supabase's own advisor flags the same thing.
540
+ *
541
+ * Read, never run. A function already reported as privileged says this as
542
+ * part of that finding and is not reported twice.
543
+ */
544
+ function searchPathsOf(plan) {
545
+ const reported = new Set(privilegedOf(plan).map((f) => f.fn));
546
+ return ((plan && plan.anonFunctions) || [])
547
+ .filter((fn) => (fn.callable || fn.callableSignedIn) && !fn.hasFixedSearchPath && !reported.has(fn.name))
548
+ .map((fn) => ({
549
+ kind: 'searchpath',
550
+ fn: fn.name,
551
+ table: fn.name,
552
+ args: fn.args,
553
+ writes: fn.writes,
554
+ anon: fn.callable === true,
555
+ columns: [],
556
+ }));
557
+ }
558
+
559
+ // A CHECK that keeps one column to a fixed list, as Postgres writes it back:
560
+ // CHECK ((severity = ANY (ARRAY['critical'::text, 'high'::text])))
561
+ // CHECK (((status)::text = ANY ((ARRAY['a'::character varying])::text[])))
562
+ // Mirrored in engine.sql empty_choices.
563
+ const ONE_OF = /^CHECK \(+"?([A-Za-z_][\w$]*)"?\)?(?:::[\w ]+)? = ANY \(/;
564
+
565
+ /**
566
+ * Columns kept to a fixed list by a CHECK that still take an empty value.
567
+ *
568
+ * Found on a blind test (Northstar): findings.severity had
569
+ * CHECK (severity IN ('critical', 'high', 'medium', 'low')) and no NOT NULL.
570
+ * A CHECK refuses a row only when it can show the value wrong, and NULL is
571
+ * never shown wrong - so a finding with no severity at all was accepted, and
572
+ * the app's report showed a row with no label. Only said when nothing else
573
+ * fills the column: no default, not generated, and a CHECK that does not
574
+ * mention NULL itself (a CHECK that does has decided the question).
575
+ */
576
+ function emptyChoicesOf(plan) {
577
+ return oneOfColumns(plan)
578
+ .filter((x) => !x.column.not_null && x.column.default_expr == null && !x.column.generated)
579
+ .map((x) => ({
580
+ kind: 'nullcheck',
581
+ table: x.table,
582
+ column: x.column.name,
583
+ allowed: Array.from(x.definition.matchAll(/'((?:[^']|'')*)'/g)).map((m) => m[1].split("''").join("'")),
584
+ constraint: x.constraint,
585
+ columns: [],
586
+ }));
587
+ }
588
+
589
+ /** Every column a CHECK keeps to a fixed list - each one looked at, empty or not. */
590
+ function oneOfColumns(plan) {
591
+ const found = [];
592
+ for (const table of (plan && plan.tables) || []) {
593
+ for (const rule of table.constraints || []) {
594
+ if (rule.kind !== 'c') continue;
595
+ const def = String(rule.definition || '');
596
+ if (/\b(or|null|and)\b/i.test(def)) continue;
597
+ const m = ONE_OF.exec(def);
598
+ if (!m) continue;
599
+ const column = (table.columns || []).find((c) => c.name === m[1]);
600
+ if (column) found.push({ table: table.name, column: column, constraint: rule.name, definition: def });
601
+ }
602
+ }
603
+ return found;
604
+ }
605
+
606
+ /**
607
+ * Every INSERT ... ON CONFLICT (...) in the app's functions, with the table it
608
+ * writes and the conflict target, read from the function's text. Mirrored in
609
+ * engine.sql conflict_targets, step for step: statements split on ";", the
610
+ * table after INSERT INTO, the columns inside ON CONFLICT ( ), and a WHERE
611
+ * right after them up to DO.
612
+ */
613
+ function conflictTargetsOf(plan) {
614
+ const tables = new Set(((plan && plan.tables) || []).map((t) => t.name));
615
+ const unquote = (part) => (part.startsWith('"') ? part.slice(1, -1) : part.toLowerCase());
616
+ const seen = new Set();
617
+ const out = [];
618
+ for (const fn of (plan && plan.functions) || []) {
619
+ const body = String(fn.src || '').replace(/--[^\n]*/g, ' ');
620
+ for (const statement of body.split(';')) {
621
+ const into = /(?:^|[^\w$])insert\s+into\s+((?:"[^"]+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"[^"]+"|[A-Za-z_][\w$]*))?)/i.exec(statement);
622
+ if (!into) continue;
623
+ const on = /(?:^|[^\w$])on\s+conflict\s*\(([^)]*)\)([\s\S]*)$/i.exec(statement);
624
+ if (!on || !on[1].trim()) continue;
625
+ const parts = into[1].match(/"[^"]+"|[A-Za-z_][\w$]*/g);
626
+ if (parts.length === 2 && unquote(parts[0]) !== plan.schema) continue;
627
+ const target = unquote(parts[parts.length - 1]);
628
+ if (!tables.has(target)) continue;
629
+ const where = /^\s*where\b/i.test(on[2]) ? on[2].split(/\bdo\b/i)[0].trim() : '';
630
+ const key = [fn.name, target, on[1].trim(), where].join('|');
631
+ if (seen.has(key)) continue;
632
+ seen.add(key);
633
+ out.push({ fn: fn.name, args: fn.args || '', target: target, on: on[1].trim(), where: where });
634
+ }
635
+ }
636
+ return out;
637
+ }
638
+
639
+ /**
640
+ * The WHERE of a partial unique index on exactly these columns, if there is
641
+ * one: what the ON CONFLICT has to repeat to be matched. Mirrored in
642
+ * engine.sql partial_where_for.
643
+ */
644
+ function partialWhereFor(indexes, table, on) {
645
+ const norm = (text) => String(text).replace(/["\s]/g, '').toLowerCase();
646
+ for (const index of indexes || []) {
647
+ if (index.table_name !== table) continue;
648
+ const m = /USING \w+ \(([^)]*)\)(?: WHERE (.*))?$/.exec(String(index.definition || ''));
649
+ if (m && m[2] && norm(m[1]) === norm(on)) return m[2];
650
+ }
651
+ return null;
652
+ }
653
+
654
+ /**
655
+ * The upserts Postgres refuses to run, asked on the copy.
656
+ *
657
+ * Found on a blind test (Northstar): upsert_project said
658
+ * ON CONFLICT (organization_id, external_ref), and the only unique index on
659
+ * those columns was partial (WHERE external_ref IS NOT NULL). Postgres can
660
+ * only use a partial index when the ON CONFLICT repeats its WHERE, so it
661
+ * refused the statement and the function failed on every call.
662
+ *
663
+ * Nothing of the app's is run. Each statement is put to the copy as
664
+ * EXPLAIN INSERT ... DEFAULT VALUES ON CONFLICT (...) DO NOTHING - planned,
665
+ * never executed - and only Postgres's own "no unique or exclusion constraint
666
+ * matching" (42P10) counts. Any other error says nothing either way.
667
+ */
668
+ async function conflictsIn(client, into, plan, indexes) {
669
+ const found = [];
670
+ for (const t of conflictTargetsOf(plan)) {
671
+ let refused = false;
672
+ await client.query('BEGIN');
673
+ try {
674
+ await client.query('EXPLAIN INSERT INTO ' + schema.quote(into) + '.' + schema.quote(t.target) +
675
+ ' DEFAULT VALUES ON CONFLICT (' + t.on + ')' + (t.where ? ' ' + t.where : '') + ' DO NOTHING');
676
+ } catch (err) {
677
+ refused = err.code === '42P10';
678
+ } finally {
679
+ await client.query('ROLLBACK').catch(() => {});
680
+ }
681
+ if (!refused) continue;
682
+ found.push({
683
+ kind: 'conflict',
684
+ fn: t.fn,
685
+ table: t.fn,
686
+ args: t.args,
687
+ target: t.target,
688
+ on: t.on,
689
+ partialWhere: partialWhereFor(indexes, t.target, t.on),
690
+ columns: [],
691
+ });
692
+ }
693
+ return found;
694
+ }
695
+
696
+ /**
697
+ * The app's functions that were read and never called: anything a visitor or
698
+ * a signed-in user can call, that no rule calls for them (those ran with every
699
+ * read), and that no finding already names. Said under "What I did not test".
700
+ *
701
+ * Found on a blind test (Northstar): the report on the repository said every
702
+ * attack ran, while upsert_project - broken on every call - and record_scan
703
+ * had never been run at all.
704
+ */
705
+ function notCalledOf(plan, findings) {
706
+ const escape = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
707
+ const rules = ((plan && plan.policies) || []).map((p) => String(p.qual || '') + ' ' + String(p.with_check || '')).join(' ');
708
+ const named = new Set((findings || []).map((f) => f.fn).filter(Boolean));
709
+ const names = ((plan && plan.functions) || [])
710
+ .filter((fn) => fn.client_callable === true && !named.has(fn.name))
711
+ .map((fn) => fn.name)
712
+ .filter((name) => !new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?\\s*\\(').test(rules));
713
+ return Array.from(new Set(names)).sort();
714
+ }
715
+
514
716
  /**
515
717
  * A public bucket whose read rule says it was meant to be private.
516
718
  *
@@ -651,6 +853,7 @@ function nightlyRecord(latest, readAt, plan) {
651
853
  stopped: run.stopped || null,
652
854
  attacksRun: run.attacks_run || 0,
653
855
  notChecked: notCheckedFromNight(run.not_checked),
856
+ notCalled: run.not_called || [],
654
857
  findings: run.stopped ? [] : finding.describeAll(night.concat(now)),
655
858
  functionsReadAt: plan ? readAt : null,
656
859
  });
@@ -720,6 +923,20 @@ function notTestedLines(result) {
720
923
  lines.push(' looking exactly like an attack that was refused.');
721
924
  }
722
925
 
926
+ // Found on a blind test (Northstar): "every attack ran" was printed over an
927
+ // upsert that failed on every call, because no attack calls the app's own
928
+ // functions. They are named, so the sentence above is read as being about
929
+ // tables only.
930
+ const notCalled = result.notCalled || [];
931
+ if (notCalled.length) {
932
+ lines.push('');
933
+ wrap('Your app\'s ' + (notCalled.length === 1 ? 'function ' : 'functions ') +
934
+ andList(notCalled.map((name) => name + '()')) + (notCalled.length === 1 ? ' was' : ' were') +
935
+ ' read, never called. Whether ' + (notCalled.length === 1 ? 'it works' : 'they work') +
936
+ ', and what ' + (notCalled.length === 1 ? 'it lets' : 'they let') + ' a caller do, is not tested here.', 68)
937
+ .forEach((l) => lines.push(' ' + l));
938
+ }
939
+
723
940
  // Said every time, whether or not the collision attack ran. It is the one
724
941
  // limit that is structural rather than circumstantial, and a person
725
942
  // deciding how far to trust this deserves to know it is there.
@@ -850,6 +1067,11 @@ function report(result) {
850
1067
  }
851
1068
 
852
1069
  /** Wraps at a width a person can read without their eyes sliding off. */
1070
+ /** a, b and c */
1071
+ function andList(items) {
1072
+ return items.length < 2 ? items.join('') : items.slice(0, -1).join(', ') + ' and ' + items[items.length - 1];
1073
+ }
1074
+
853
1075
  function wrap(text, width) {
854
1076
  const words = String(text).split(/\s+/);
855
1077
  const lines = [];
@@ -972,6 +1194,13 @@ module.exports = {
972
1194
  scheduleTimes: scheduleTimes,
973
1195
  notCheckedFromNight: notCheckedFromNight,
974
1196
  privilegedOf: privilegedOf,
1197
+ searchPathsOf: searchPathsOf,
1198
+ emptyChoicesOf: emptyChoicesOf,
1199
+ oneOfColumns: oneOfColumns,
1200
+ conflictTargetsOf: conflictTargetsOf,
1201
+ partialWhereFor: partialWhereFor,
1202
+ conflictsIn: conflictsIn,
1203
+ notCalledOf: notCalledOf,
975
1204
  bucketsOf: bucketsOf,
976
1205
  loopingTables: loopingTables,
977
1206
  taggerFor: taggerFor,
package/schema.js CHANGED
@@ -291,7 +291,9 @@ async function readAnonDefinerFunctions(client, schema, role) {
291
291
  pg_get_functiondef(p.oid) AS def,
292
292
  p.prorettype = 'pg_catalog.bool'::regtype AS returns_bool,
293
293
  (SELECT t.typtype = 'e' FROM pg_type t WHERE t.oid = p.prorettype) AS returns_enum,
294
- ${exists.rows.length ? "has_function_privilege($2, p.oid, 'EXECUTE')" : 'false'} AS callable
294
+ ${exists.rows.length ? "has_function_privilege($2, p.oid, 'EXECUTE')" : 'false'} AS callable,
295
+ CASE WHEN EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated')
296
+ THEN has_function_privilege('authenticated', p.oid, 'EXECUTE') ELSE false END AS callable_signed_in
295
297
  FROM pg_proc p
296
298
  JOIN pg_namespace n ON n.oid = p.pronamespace
297
299
  WHERE n.nspname = $1
@@ -359,6 +361,10 @@ async function readAnonDefinerFunctions(client, schema, role) {
359
361
  nullSlips: door.nullSlips,
360
362
  hasFixedSearchPath: hasFixedSearchPath,
361
363
  callable: row.callable === true,
364
+ // Signed-in users too. A definer function only they can call is the
365
+ // ordinary Supabase RPC and is not a privileged finding - but one that
366
+ // leaves its search_path open is still worth a look (scan.js searchPathsOf).
367
+ callableSignedIn: row.callable_signed_in === true,
362
368
  };
363
369
  });
364
370
  }
@@ -381,6 +387,14 @@ async function readFunctions(client, schema) {
381
387
  pg_get_functiondef(p.oid) AS def,
382
388
  pg_get_function_identity_arguments(p.oid) AS args,
383
389
  p.proacl IS NULL AS default_acl,
390
+ -- Whether the app can call it at all, as a visitor or signed in.
391
+ -- Such a function is something the app does that was read and
392
+ -- never run - said under "What I did not test" (scan.js notCalledOf).
393
+ CASE WHEN EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon')
394
+ AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated')
395
+ THEN has_function_privilege('anon', p.oid, 'EXECUTE')
396
+ OR has_function_privilege('authenticated', p.oid, 'EXECUTE')
397
+ ELSE false END AS client_callable,
384
398
  (SELECT coalesce(array_agg(g ORDER BY g), '{}'::text[])
385
399
  FROM (SELECT DISTINCT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee)::text END AS g
386
400
  FROM aclexplode(p.proacl) a
package/sqlengine.js CHANGED
@@ -161,6 +161,14 @@ async function collide(client, target, into, tables, indexes) {
161
161
  * that is measured and written up in STATE.md, and the report names what it
162
162
  * could not race rather than leaving it out.
163
163
  */
164
+ async function conflicts(client, target, into, plan, indexes) {
165
+ const { rows } = await client.query(
166
+ 'SELECT ' + quote(target) + '.conflicts($1, $2::jsonb, $3::jsonb) AS answer',
167
+ [into, JSON.stringify(plan), JSON.stringify(indexes || [])],
168
+ );
169
+ return rows[0].answer;
170
+ }
171
+
164
172
  function adapterFor(target) {
165
173
  return {
166
174
  name: 'sql',
@@ -172,6 +180,7 @@ function adapterFor(target) {
172
180
  tamper(client, target, into, tables, sown, policies),
173
181
  orphan: (client, into, tables, sown) => orphan(client, target, into, tables, sown),
174
182
  teammate: (client, into, tables, sown, rules) => teammate(client, target, into, tables, sown, rules),
183
+ conflicts: (client, into, plan, indexes) => conflicts(client, target, into, plan, indexes),
175
184
  };
176
185
  }
177
186
 
@@ -206,6 +215,7 @@ module.exports = {
206
215
  orphan: orphan,
207
216
  teammate: teammate,
208
217
  collide: collide,
218
+ conflicts: conflicts,
209
219
  adapterFor: adapterFor,
210
220
  withEngine: withEngine,
211
221
  };