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 +1 -1
- package/README.md +12 -4
- package/engine.sql +187 -5
- package/finding.js +133 -8
- package/installer.js +4 -2
- package/package.json +2 -2
- package/recheck.js +10 -0
- package/scan.js +232 -3
- package/schema.js +15 -1
- package/sqlengine.js +10 -0
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
932
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
530
|
-
|
|
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.
|
|
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
|
};
|