kryptheon-night 0.1.27 → 0.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/bin/kryptheon-night.js +33 -5
- package/collision.js +15 -0
- package/engine.sql +181 -27
- package/finding.js +33 -18
- package/installer.js +64 -2
- package/intro.js +16 -3
- package/package.json +3 -2
- package/scan.js +5 -2
- package/sqlengine.js +4 -4
- package/tamper.js +97 -22
package/README.md
CHANGED
|
@@ -74,7 +74,7 @@ whether they are meant:
|
|
|
74
74
|
|
|
75
75
|
| check | the question |
|
|
76
76
|
|---|---|
|
|
77
|
-
| **
|
|
77
|
+
| **Team roles** | which role in a team, from the lowest up, can change what its rules do not list it for, and which can read its tokens, keys and payloads? (tried on the copy, undone) |
|
|
78
78
|
| **Storage** | is a bucket public although a rule says who may read it? (settings only, never a file) |
|
|
79
79
|
| **Open functions** | can a visitor with no account call a function that skips the rules? |
|
|
80
80
|
|
|
@@ -149,6 +149,15 @@ much faster for the same reason: on a real project the command line took 2
|
|
|
149
149
|
minutes 16 seconds and the nightly run took 1 second, and both found the same
|
|
150
150
|
five problems.
|
|
151
151
|
|
|
152
|
+
**The one exception is one you ask for.** On Pro or Studio, install with the
|
|
153
|
+
project token from your kryptheon.tech dashboard in the environment
|
|
154
|
+
(`KRYPTHEON_PROJECT_TOKEN`, and `KRYPTHEON_REPORT_URL` for where it goes) and
|
|
155
|
+
each night sends that dashboard a summary: the kind of each finding, the table
|
|
156
|
+
it is on, and three counts. Never a row, never a value, never your connection
|
|
157
|
+
string — `report.check.js` reads exactly what would be sent and fails if any of
|
|
158
|
+
those is in it. The token is kept where only the database owner can read it.
|
|
159
|
+
Install again without it and the token is deleted and nothing is sent.
|
|
160
|
+
|
|
152
161
|
**One thing it cannot do.** Racing two requests against each other — the "can
|
|
153
162
|
this exist twice" question — needs two connections at the same instant, and
|
|
154
163
|
nothing living inside a database has them. The nightly report names every
|
package/bin/kryptheon-night.js
CHANGED
|
@@ -35,6 +35,32 @@ const installer = require('../installer.js');
|
|
|
35
35
|
const outside = require('../outside.js');
|
|
36
36
|
const schema = require('../schema.js');
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The nightly summary, for a person on Pro or Studio: the project token and
|
|
40
|
+
* the address it goes to, from the environment - never the command line,
|
|
41
|
+
* which ends up in shell history. Null when no token is given, and then the
|
|
42
|
+
* night sends nothing at all.
|
|
43
|
+
*/
|
|
44
|
+
function reportToFromEnv() {
|
|
45
|
+
const token = String(process.env.KRYPTHEON_PROJECT_TOKEN || '').trim();
|
|
46
|
+
if (!token) return null;
|
|
47
|
+
return {
|
|
48
|
+
token: token,
|
|
49
|
+
endpoint: String(process.env.KRYPTHEON_REPORT_URL || '').trim(),
|
|
50
|
+
engine: engineVersion(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** "kryptheon-night 0.1.30", for the dashboard - or just the name, if it cannot be read. */
|
|
55
|
+
function engineVersion() {
|
|
56
|
+
try {
|
|
57
|
+
const manifest = JSON.parse(require('fs').readFileSync(require('path').join(__dirname, '..', 'package.json'), 'utf8'));
|
|
58
|
+
return 'kryptheon-night ' + manifest.version;
|
|
59
|
+
} catch (err) {
|
|
60
|
+
return 'kryptheon-night';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
38
64
|
const line = (text) => process.stdout.write(text + '\n');
|
|
39
65
|
const fail = (text) => process.stderr.write(text + '\n');
|
|
40
66
|
|
|
@@ -205,9 +231,10 @@ async function runVerb(command, client, target) {
|
|
|
205
231
|
|
|
206
232
|
if (command === 'install') {
|
|
207
233
|
line(' Installing ...');
|
|
208
|
-
const done = await installer.install(client, { source: target });
|
|
234
|
+
const done = await installer.install(client, { source: target, reportTo: reportToFromEnv() });
|
|
209
235
|
line('');
|
|
210
236
|
line(' Installed. It will run at ' + done.at + ', watching "' + done.source + '".');
|
|
237
|
+
if (done.reporting) line(' Each night it sends your Kryptheon dashboard a summary - never a row of data.');
|
|
211
238
|
line('');
|
|
212
239
|
if (done.made.extensions.length) {
|
|
213
240
|
line(' I had to add: ' + done.made.extensions.join(', ') + '.');
|
|
@@ -352,7 +379,7 @@ async function offerTheNight(client, target, canAsk) {
|
|
|
352
379
|
return;
|
|
353
380
|
}
|
|
354
381
|
|
|
355
|
-
block(intro.installConsentLines(installer.SCHEMA, target, installer.AT));
|
|
382
|
+
block(intro.installConsentLines(installer.SCHEMA, target, installer.AT, reportToFromEnv()));
|
|
356
383
|
const sure = await intro.askYesNo(' Go ahead? (y/n) ');
|
|
357
384
|
if (!sure) {
|
|
358
385
|
line('');
|
|
@@ -362,9 +389,10 @@ async function offerTheNight(client, target, canAsk) {
|
|
|
362
389
|
}
|
|
363
390
|
|
|
364
391
|
try {
|
|
365
|
-
const done = await installer.install(client, { source: target });
|
|
392
|
+
const done = await installer.install(client, { source: target, reportTo: reportToFromEnv() });
|
|
366
393
|
line('');
|
|
367
394
|
line(' Done. It will run at ' + done.at + ', watching "' + done.source + '".');
|
|
395
|
+
if (done.reporting) line(' Each night it sends your Kryptheon dashboard a summary - never a row of data.');
|
|
368
396
|
if (done.made.extensions.length) {
|
|
369
397
|
line(' I added ' + done.made.extensions.join(' and ') + '; uninstall takes them back out.');
|
|
370
398
|
}
|
|
@@ -429,7 +457,7 @@ function wordsFor(target, connection) {
|
|
|
429
457
|
return {
|
|
430
458
|
help: intro.whereToFindIt(),
|
|
431
459
|
consent: intro.consentLines(target),
|
|
432
|
-
installConsent: intro.installConsentLines(installer.SCHEMA, target, installer.AT),
|
|
460
|
+
installConsent: intro.installConsentLines(installer.SCHEMA, target, installer.AT, reportToFromEnv()),
|
|
433
461
|
// pg_cron reads this in the database's own time zone, which is UTC on
|
|
434
462
|
// Supabase; the dashboard turns it into the person's local time.
|
|
435
463
|
nightlyAt: installer.AT,
|
|
@@ -541,7 +569,7 @@ async function main() {
|
|
|
541
569
|
// ends "I do not leave anything behind", which is true of a scan and false
|
|
542
570
|
// of an install - and this is the one screen the product cannot be loose on.
|
|
543
571
|
block(asked.command === 'install'
|
|
544
|
-
? intro.installConsentLines(installer.SCHEMA, target, installer.AT)
|
|
572
|
+
? intro.installConsentLines(installer.SCHEMA, target, installer.AT, reportToFromEnv())
|
|
545
573
|
: intro.consentLines(target));
|
|
546
574
|
const yes = await intro.askYesNo(' Go ahead? (y/n) ');
|
|
547
575
|
if (!yes) {
|
package/collision.js
CHANGED
|
@@ -69,6 +69,20 @@ const MUST_BE_UNIQUE = [
|
|
|
69
69
|
*/
|
|
70
70
|
const ACCOUNT_TABLE = /^(users?|profiles?|accounts?|members?|auth_users|app_users|logins?)$/i;
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Tables that keep another service's credentials, so this app can call it.
|
|
74
|
+
*
|
|
75
|
+
* A token there was issued by somebody else and opens nothing in this app, so
|
|
76
|
+
* two rows sharing one is not a way in. Found on a blind test (LaunchRail):
|
|
77
|
+
* integration_credentials.access_token and refresh_token were reported as
|
|
78
|
+
* "can exist twice" - not in the answer key, and not a door into the app.
|
|
79
|
+
* Known by the table's name, or by a `provider` column naming the service.
|
|
80
|
+
*/
|
|
81
|
+
const OUTBOUND_TABLE = /(^|_)(integrations?|providers?|oauth|connections?|connectors?|external|third_party)(_|$)/i;
|
|
82
|
+
function holdsOutboundCredentials(table) {
|
|
83
|
+
return OUTBOUND_TABLE.test(table.name) || (table.columns || []).some((c) => /^provider$/i.test(c.name));
|
|
84
|
+
}
|
|
85
|
+
|
|
72
86
|
/** Everything on a table that already promises "only one of these". */
|
|
73
87
|
function uniquenessTexts(table, indexes) {
|
|
74
88
|
const fromConstraints = (table.constraints || [])
|
|
@@ -118,6 +132,7 @@ function candidates(tables, indexes) {
|
|
|
118
132
|
const rule = MUST_BE_UNIQUE.find((r) => r.test.test(column.name));
|
|
119
133
|
if (!rule) continue;
|
|
120
134
|
if (rule.accountTablesOnly && !ACCOUNT_TABLE.test(table.name)) continue;
|
|
135
|
+
if (rule.expectation === 'credential' && holdsOutboundCredentials(table)) continue;
|
|
121
136
|
found.push({
|
|
122
137
|
table: table.name,
|
|
123
138
|
column: column.name,
|
package/engine.sql
CHANGED
|
@@ -2493,7 +2493,7 @@ BEGIN
|
|
|
2493
2493
|
IF lowest IS NOT NULL THEN
|
|
2494
2494
|
-- Every role not plainly in charge, the lowest first and then upwards:
|
|
2495
2495
|
-- the order reads are tried in.
|
|
2496
|
-
RETURN jsonb_build_object('column', col->>'name', 'lowest', lowest, 'ladder',
|
|
2496
|
+
RETURN jsonb_build_object('column', col->>'name', 'type', col->>'type', 'lowest', lowest, 'ladder',
|
|
2497
2497
|
jsonb_build_array(lowest) || coalesce((
|
|
2498
2498
|
SELECT jsonb_agg(l ORDER BY pos DESC)
|
|
2499
2499
|
FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, pos)
|
|
@@ -2563,7 +2563,7 @@ BEGIN
|
|
|
2563
2563
|
CONTINUE WHEN jsonb_array_length(keys) = 0 OR __KN__.alone_unique(tab, person);
|
|
2564
2564
|
found := found || jsonb_build_array(jsonb_build_object(
|
|
2565
2565
|
'table', tab, 'person', person, 'role', role_->>'column', 'lowest', role_->>'lowest',
|
|
2566
|
-
'ladder', role_->'ladder', 'keys', keys));
|
|
2566
|
+
'roleType', role_->>'type', 'ladder', role_->'ladder', 'keys', keys));
|
|
2567
2567
|
END LOOP;
|
|
2568
2568
|
-- A table named for members first: it is the one a team rule asks.
|
|
2569
2569
|
SELECT coalesce(jsonb_agg(m ORDER BY (m->'table'->>'name') !~* 'member', at), '[]'::jsonb) INTO found
|
|
@@ -2623,7 +2623,49 @@ RETURNS bigint LANGUAGE sql IMMUTABLE AS $$
|
|
|
2623
2623
|
ELSE 0 END;
|
|
2624
2624
|
$$;
|
|
2625
2625
|
|
|
2626
|
-
|
|
2626
|
+
/*
|
|
2627
|
+
* Whether the app's own rules name this role among those allowed to make
|
|
2628
|
+
* this kind of write: the role in a list in a permissive rule for that
|
|
2629
|
+
* command, or in a list inside a helper that rule calls. A rule written for
|
|
2630
|
+
* one role on its own (role = 'analyst', LaunchRail) is not a list of who may
|
|
2631
|
+
* write. Mirrors allowedByRules in tamper.js.
|
|
2632
|
+
*/
|
|
2633
|
+
CREATE OR REPLACE FUNCTION __KN__.allowed_by_rules(rules jsonb, table_name text, what text, role_ text, type_ text)
|
|
2634
|
+
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
|
|
2635
|
+
DECLARE
|
|
2636
|
+
esc constant text := '([.*+?^${}()|\[\]\\])';
|
|
2637
|
+
label text := '''' || regexp_replace(replace(role_, '''', ''''''), esc, '\\\1', 'g') || '''';
|
|
2638
|
+
type_name text := regexp_replace(replace(split_part(coalesce(type_, 'text'), '.', -1), '"', ''), esc, '\\\1', 'g');
|
|
2639
|
+
in_rule_list text := 'ARRAY\[[^]]*' || label || '::("?[A-Za-z0-9_$]+"?\.)?"?' || type_name || '"?';
|
|
2640
|
+
-- The same list written as an array literal, kept as '{...}'::role[].
|
|
2641
|
+
in_rule_literal text := '''\{([^}'']*,)?"?' || regexp_replace(role_, esc, '\\\1', 'g') ||
|
|
2642
|
+
'"?(,[^}'']*)?\}''::("?[A-Za-z0-9_$]+"?\.)?"?' || type_name || '"?\[\]';
|
|
2643
|
+
in_helper_list text := '(\mIN\s*\(|ARRAY\[)[^])]*' || label;
|
|
2644
|
+
command text := CASE what WHEN 'add' THEN 'INSERT' WHEN 'change' THEN 'UPDATE' WHEN 'delete' THEN 'DELETE' END;
|
|
2645
|
+
policy jsonb;
|
|
2646
|
+
body text;
|
|
2647
|
+
BEGIN
|
|
2648
|
+
FOR policy IN SELECT * FROM jsonb_array_elements(coalesce(rules->'policies', '[]'::jsonb)) LOOP
|
|
2649
|
+
CONTINUE WHEN policy->>'table_name' <> table_name;
|
|
2650
|
+
CONTINUE WHEN upper(coalesce(policy->>'permissive', 'PERMISSIVE')) <> 'PERMISSIVE';
|
|
2651
|
+
CONTINUE WHEN upper(coalesce(policy->>'cmd', 'ALL')) NOT IN ('ALL', command);
|
|
2652
|
+
body := coalesce(policy->>'qual', '') || ' ' || coalesce(policy->>'with_check', '');
|
|
2653
|
+
IF body ~* in_rule_list OR body ~* in_rule_literal THEN RETURN true; END IF;
|
|
2654
|
+
IF EXISTS (
|
|
2655
|
+
SELECT 1 FROM jsonb_array_elements(coalesce(rules->'functions', '[]'::jsonb)) f
|
|
2656
|
+
WHERE __KN__.calls_function(body, f->>'name')
|
|
2657
|
+
AND coalesce(f->>'src', '') ~* in_helper_list) THEN
|
|
2658
|
+
RETURN true;
|
|
2659
|
+
END IF;
|
|
2660
|
+
END LOOP;
|
|
2661
|
+
RETURN false;
|
|
2662
|
+
END $$;
|
|
2663
|
+
|
|
2664
|
+
-- The three-argument teammate of 0.1.26-0.1.28, dropped so an engine
|
|
2665
|
+
-- upgraded in place keeps one version and every call stays unambiguous.
|
|
2666
|
+
DROP FUNCTION IF EXISTS __KN__.teammate(text, jsonb, jsonb);
|
|
2667
|
+
|
|
2668
|
+
CREATE OR REPLACE FUNCTION __KN__.teammate(source text, tables jsonb, seeded jsonb, rules jsonb)
|
|
2627
2669
|
RETURNS jsonb LANGUAGE plpgsql AS $$
|
|
2628
2670
|
DECLARE
|
|
2629
2671
|
findings jsonb := '[]'::jsonb;
|
|
@@ -2668,6 +2710,9 @@ DECLARE
|
|
|
2668
2710
|
role_ text;
|
|
2669
2711
|
tried jsonb;
|
|
2670
2712
|
i integer;
|
|
2713
|
+
before_all jsonb;
|
|
2714
|
+
below jsonb;
|
|
2715
|
+
listed boolean;
|
|
2671
2716
|
BEGIN
|
|
2672
2717
|
FOR member IN SELECT * FROM jsonb_array_elements(__KN__.memberships(tables)) LOOP
|
|
2673
2718
|
SELECT (s->>'attempt')::integer INTO shape
|
|
@@ -2743,7 +2788,6 @@ BEGIN
|
|
|
2743
2788
|
FROM jsonb_array_elements(coalesce(seeded, '[]'::jsonb)) s
|
|
2744
2789
|
WHERE s->>'table' = tab->>'name';
|
|
2745
2790
|
shape := coalesce(shape, 0);
|
|
2746
|
-
finding_key := 'role:' || (tab->>'name') || ':' || (member->>'lowest');
|
|
2747
2791
|
owner := __KN__.owner_column(tab);
|
|
2748
2792
|
|
|
2749
2793
|
-- A table that names the team itself, and the team it is to name.
|
|
@@ -2786,52 +2830,84 @@ BEGIN
|
|
|
2786
2830
|
-- may only look, and written under the other person's name: a rule that
|
|
2787
2831
|
-- lets a member add a row only as themselves is them speaking for
|
|
2788
2832
|
-- themselves.
|
|
2789
|
-
IF
|
|
2833
|
+
IF to_team IS NOT NULL AND EXISTS (
|
|
2834
|
+
SELECT 1 FROM jsonb_array_elements_text(member->'ladder') r WHERE __KN__.look_only(r)) THEN
|
|
2790
2835
|
BEGIN
|
|
2791
2836
|
row_ := __KN__.row_for(source, tab, __KN__.user_a(), '11', pointed, shape);
|
|
2792
|
-
moves := jsonb_build_array(jsonb_build_object('what', 'add',
|
|
2837
|
+
moves := jsonb_build_array(jsonb_build_object('what', 'add', 'lookOnly', true,
|
|
2793
2838
|
'statement', __KN__.insert_statement(source, tab->>'name', row_))) || moves;
|
|
2794
2839
|
EXCEPTION WHEN OTHERS THEN
|
|
2795
2840
|
NULL;
|
|
2796
2841
|
END;
|
|
2797
2842
|
END IF;
|
|
2798
2843
|
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2844
|
+
-- Every role not in charge, from the lowest up, the way reads are tried
|
|
2845
|
+
-- (LaunchRail: production gave "analyst" its own UPDATE rule and only
|
|
2846
|
+
-- "viewer" was tried). The first role that can write where the app's own
|
|
2847
|
+
-- rules do not list it is the one named. What each write did before
|
|
2848
|
+
-- joining is asked once.
|
|
2849
|
+
before_all := '{}'::jsonb;
|
|
2802
2850
|
FOR move IN SELECT * FROM jsonb_array_elements(moves) LOOP
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
was := __KN__.rows_moved(before_);
|
|
2806
|
-
now_ := __KN__.rows_moved(after_);
|
|
2807
|
-
IF was IS NULL OR now_ IS NULL THEN
|
|
2808
|
-
stuck := CASE WHEN now_ IS NULL THEN after_->>'why' ELSE before_->>'why' END;
|
|
2809
|
-
ELSIF now_ > was THEN
|
|
2810
|
-
can := can || to_jsonb(move->>'what');
|
|
2811
|
-
changed := jsonb_set(changed, ARRAY[move->>'what'], to_jsonb(now_ - was));
|
|
2812
|
-
END IF;
|
|
2851
|
+
before_all := jsonb_set(before_all, ARRAY[move->>'what'],
|
|
2852
|
+
__KN__.try_write('authenticated', __KN__.user_b(), move->>'statement'));
|
|
2813
2853
|
END LOOP;
|
|
2854
|
+
below := '[]'::jsonb;
|
|
2855
|
+
FOR role_ IN SELECT jsonb_array_elements_text(member->'ladder') LOOP
|
|
2856
|
+
finding_key := 'role:' || (tab->>'name') || ':' || role_;
|
|
2857
|
+
IF NOT (joins ? role_) THEN
|
|
2858
|
+
blocked := blocked || jsonb_build_array(jsonb_build_object(
|
|
2859
|
+
'table', tab->>'name', 'key', finding_key,
|
|
2860
|
+
'why', 'as a "' || role_ || '" in ' || (member->'table'->>'name') || ': I could not add one'));
|
|
2861
|
+
EXIT;
|
|
2862
|
+
END IF;
|
|
2863
|
+
can := '[]'::jsonb;
|
|
2864
|
+
changed := '{}'::jsonb;
|
|
2865
|
+
stuck := NULL;
|
|
2866
|
+
listed := false;
|
|
2867
|
+
FOR move IN SELECT * FROM jsonb_array_elements(moves) LOOP
|
|
2868
|
+
CONTINUE WHEN coalesce((move->>'lookOnly')::boolean, false) AND NOT __KN__.look_only(role_);
|
|
2869
|
+
-- A write the rules list this role for is not asked: whatever it did
|
|
2870
|
+
-- would not be reported, and a key refusing it left the table untested.
|
|
2871
|
+
IF __KN__.allowed_by_rules(rules, tab->>'name', move->>'what', role_, member->>'roleType') THEN
|
|
2872
|
+
listed := true;
|
|
2873
|
+
CONTINUE;
|
|
2874
|
+
END IF;
|
|
2875
|
+
before_ := before_all->(move->>'what');
|
|
2876
|
+
after_ := __KN__.try_write_joined('authenticated', __KN__.user_b(), move->>'statement', joins->>role_);
|
|
2877
|
+
was := __KN__.rows_moved(before_);
|
|
2878
|
+
now_ := __KN__.rows_moved(after_);
|
|
2879
|
+
IF was IS NULL OR now_ IS NULL THEN
|
|
2880
|
+
stuck := CASE WHEN now_ IS NULL THEN after_->>'why' ELSE before_->>'why' END;
|
|
2881
|
+
ELSIF now_ > was THEN
|
|
2882
|
+
can := can || to_jsonb(move->>'what');
|
|
2883
|
+
changed := jsonb_set(changed, ARRAY[move->>'what'], to_jsonb(now_ - was));
|
|
2884
|
+
END IF;
|
|
2885
|
+
END LOOP;
|
|
2814
2886
|
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2887
|
+
IF jsonb_array_length(can) = 0 AND stuck IS NOT NULL THEN
|
|
2888
|
+
blocked := blocked || jsonb_build_array(jsonb_build_object(
|
|
2889
|
+
'table', tab->>'name', 'key', finding_key,
|
|
2890
|
+
'why', 'as a "' || role_ || '" in ' || (member->'table'->>'name') || ': ' || stuck));
|
|
2891
|
+
EXIT;
|
|
2892
|
+
END IF;
|
|
2820
2893
|
completed := completed || to_jsonb(finding_key);
|
|
2821
2894
|
IF jsonb_array_length(can) > 0 THEN
|
|
2822
2895
|
findings := findings || jsonb_build_array(jsonb_build_object(
|
|
2823
2896
|
'kind', 'role',
|
|
2824
2897
|
'table', tab->>'name',
|
|
2825
|
-
'who',
|
|
2898
|
+
'who', role_,
|
|
2826
2899
|
'via', member->'table'->>'name',
|
|
2827
2900
|
'roleColumn', member->>'role',
|
|
2828
2901
|
'parent', parent,
|
|
2829
2902
|
'can', can,
|
|
2830
2903
|
'changed', changed,
|
|
2904
|
+
'below', below,
|
|
2831
2905
|
'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c), '[]'::jsonb),
|
|
2832
2906
|
'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
|
|
2907
|
+
EXIT;
|
|
2833
2908
|
END IF;
|
|
2834
|
-
|
|
2909
|
+
IF NOT listed THEN below := below || to_jsonb(role_); END IF;
|
|
2910
|
+
END LOOP;
|
|
2835
2911
|
|
|
2836
2912
|
-- Reading what a member should not see, said only of a table holding a
|
|
2837
2913
|
-- secret. Every role not in charge, from the lowest up; the first that
|
|
@@ -3222,11 +3298,20 @@ DECLARE
|
|
|
3222
3298
|
tab jsonb;
|
|
3223
3299
|
col jsonb;
|
|
3224
3300
|
expectation text;
|
|
3301
|
+
outbound boolean;
|
|
3225
3302
|
BEGIN
|
|
3226
3303
|
FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
|
|
3304
|
+
-- A table keeping another service's credentials, so this app can call
|
|
3305
|
+
-- it: a token there opens nothing in this app, and two rows sharing one
|
|
3306
|
+
-- is not a way in. Found on a blind test (LaunchRail), where
|
|
3307
|
+
-- integration_credentials.access_token was reported as "can exist twice".
|
|
3308
|
+
outbound := tab->>'name' ~* '(^|_)(integrations?|providers?|oauth|connections?|connectors?|external|third_party)(_|$)'
|
|
3309
|
+
OR EXISTS (SELECT 1 FROM jsonb_array_elements(coalesce(tab->'columns', '[]'::jsonb)) c
|
|
3310
|
+
WHERE c->>'name' ~* '^provider$');
|
|
3227
3311
|
FOR col IN SELECT * FROM jsonb_array_elements(coalesce(tab->'columns', '[]'::jsonb)) LOOP
|
|
3228
3312
|
expectation := __KN__.must_be_unique(col->>'name', tab->>'name');
|
|
3229
3313
|
CONTINUE WHEN expectation IS NULL;
|
|
3314
|
+
CONTINUE WHEN expectation = 'credential' AND outbound;
|
|
3230
3315
|
found := found || jsonb_build_array(jsonb_build_object(
|
|
3231
3316
|
'table', tab->>'name',
|
|
3232
3317
|
'column', col->>'name',
|
|
@@ -3394,7 +3479,8 @@ BEGIN
|
|
|
3394
3479
|
|
|
3395
3480
|
seen := __KN__.impersonate(copy_name, theirs || views);
|
|
3396
3481
|
wrote := __KN__.tamper(copy_name, theirs, sown->'seeded', copy_plan->'policies');
|
|
3397
|
-
ranked := __KN__.teammate(copy_name, theirs, sown->'seeded'
|
|
3482
|
+
ranked := __KN__.teammate(copy_name, theirs, sown->'seeded',
|
|
3483
|
+
jsonb_build_object('policies', copy_plan->'policies', 'functions', copy_plan->'functions'));
|
|
3398
3484
|
stranded := __KN__.orphan(copy_name, theirs, sown->'seeded');
|
|
3399
3485
|
raced := __KN__.collide(copy_name, theirs, copy_plan->'indexes');
|
|
3400
3486
|
|
|
@@ -3449,3 +3535,71 @@ BEGIN
|
|
|
3449
3535
|
|
|
3450
3536
|
RETURN run_id;
|
|
3451
3537
|
END $$;
|
|
3538
|
+
|
|
3539
|
+
-- --------------------------------------------------------------------------
|
|
3540
|
+
-- The nightly summary, for a person on Pro or Studio.
|
|
3541
|
+
--
|
|
3542
|
+
-- Only when they asked for it at install, by giving a project token: the
|
|
3543
|
+
-- token and the address it goes to are kept in __KN__.reporting, which no
|
|
3544
|
+
-- role but the owner can read. Without that table nothing is ever sent.
|
|
3545
|
+
--
|
|
3546
|
+
-- What is sent is fixed here: the kind of each finding, the table it is on,
|
|
3547
|
+
-- a column when the finding is about one, and three counts. Never a row of
|
|
3548
|
+
-- the customer's data, never a column's values or the list of a table's
|
|
3549
|
+
-- columns, never the connection string, and not even the text of an error,
|
|
3550
|
+
-- which can quote the schema.
|
|
3551
|
+
-- --------------------------------------------------------------------------
|
|
3552
|
+
|
|
3553
|
+
/* The summary of one night, as the body of the report. */
|
|
3554
|
+
CREATE OR REPLACE FUNCTION __KN__.report_body(run_id bigint, engine text)
|
|
3555
|
+
RETURNS jsonb LANGUAGE sql STABLE AS $$
|
|
3556
|
+
SELECT jsonb_build_object(
|
|
3557
|
+
'format', 1,
|
|
3558
|
+
'engine', coalesce(engine, 'kryptheon-night'),
|
|
3559
|
+
'ran_at', r.ran_at,
|
|
3560
|
+
'stopped', r.stopped IS NOT NULL,
|
|
3561
|
+
'attacks_run', r.attacks_run,
|
|
3562
|
+
'not_tested', jsonb_array_length(r.not_checked),
|
|
3563
|
+
'findings', coalesce((
|
|
3564
|
+
SELECT jsonb_agg(jsonb_strip_nulls(jsonb_build_object(
|
|
3565
|
+
'kind', f->>'kind',
|
|
3566
|
+
-- A loop found through a helper names no table; "unknown"
|
|
3567
|
+
-- rather than nothing, so the night is still filed.
|
|
3568
|
+
'table', coalesce(f->>'table', 'unknown'),
|
|
3569
|
+
'column', f->>'column')))
|
|
3570
|
+
FROM jsonb_array_elements(r.findings) f), '[]'::jsonb))
|
|
3571
|
+
FROM __KN__.runs r WHERE r.id = run_id;
|
|
3572
|
+
$$;
|
|
3573
|
+
|
|
3574
|
+
/*
|
|
3575
|
+
* Sends one night's summary, if a project token was given at install. Never
|
|
3576
|
+
* raises: a report that cannot be sent must not cost the night its own record.
|
|
3577
|
+
*/
|
|
3578
|
+
CREATE OR REPLACE FUNCTION __KN__.report(run_id bigint)
|
|
3579
|
+
RETURNS bigint LANGUAGE plpgsql AS $$
|
|
3580
|
+
DECLARE
|
|
3581
|
+
cfg record;
|
|
3582
|
+
sent bigint;
|
|
3583
|
+
BEGIN
|
|
3584
|
+
IF to_regclass('__KN__.reporting') IS NULL THEN RETURN NULL; END IF;
|
|
3585
|
+
EXECUTE 'SELECT endpoint, token, engine FROM __KN__.reporting WHERE id = 1' INTO cfg;
|
|
3586
|
+
IF cfg IS NULL OR cfg.endpoint IS NULL OR cfg.token IS NULL THEN RETURN NULL; END IF;
|
|
3587
|
+
EXECUTE 'SELECT net.http_post(url := $1, body := $2, headers := $3, timeout_milliseconds := 10000)'
|
|
3588
|
+
INTO sent
|
|
3589
|
+
USING cfg.endpoint, __KN__.report_body(run_id, cfg.engine),
|
|
3590
|
+
jsonb_build_object('Content-Type', 'application/json', 'Authorization', 'Bearer ' || cfg.token);
|
|
3591
|
+
RETURN sent;
|
|
3592
|
+
EXCEPTION WHEN OTHERS THEN
|
|
3593
|
+
RETURN NULL;
|
|
3594
|
+
END $$;
|
|
3595
|
+
|
|
3596
|
+
/* What the scheduled job runs when a summary was asked for. */
|
|
3597
|
+
CREATE OR REPLACE FUNCTION __KN__.nightly_and_report(source text)
|
|
3598
|
+
RETURNS bigint LANGUAGE plpgsql AS $$
|
|
3599
|
+
DECLARE
|
|
3600
|
+
run_id bigint;
|
|
3601
|
+
BEGIN
|
|
3602
|
+
run_id := __KN__.nightly(source);
|
|
3603
|
+
PERFORM __KN__.report(run_id);
|
|
3604
|
+
RETURN run_id;
|
|
3605
|
+
END $$;
|
package/finding.js
CHANGED
|
@@ -89,6 +89,16 @@ function listOf(items) {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/** One of a table's things: workspaces -> workspace, companies -> company. */
|
|
92
|
+
/** A role, quoted, with its article: a "viewer", an "analyst". */
|
|
93
|
+
function aRole(role) {
|
|
94
|
+
return (/^[aeiou]/i.test(String(role)) ? 'an "' : 'a "') + role + '"';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The first letter made a capital, for the start of a sentence. */
|
|
98
|
+
function capital(text) {
|
|
99
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
92
102
|
/** The same, with its article: a workspace, an organization. */
|
|
93
103
|
function aOne(table) {
|
|
94
104
|
const one = oneOf(table);
|
|
@@ -351,11 +361,11 @@ function headlineFor(finding) {
|
|
|
351
361
|
if (finding.kind === 'role') {
|
|
352
362
|
const order = ['delete', 'change', 'add'];
|
|
353
363
|
const does = order.filter((what) => (finding.can || []).includes(what)).map((what) => WRITE_WORDS[what]);
|
|
354
|
-
return
|
|
364
|
+
return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can ' + listOf(does) + ' your ' +
|
|
355
365
|
finding.table + ' table.';
|
|
356
366
|
}
|
|
357
367
|
if (finding.kind === 'teamread') {
|
|
358
|
-
return
|
|
368
|
+
return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can read your ' + finding.table +
|
|
359
369
|
' table, which holds ' + listOf(finding.secrets || []) + '.';
|
|
360
370
|
}
|
|
361
371
|
if (finding.kind === 'bucket') {
|
|
@@ -453,13 +463,18 @@ function bodyFor(finding, contents) {
|
|
|
453
463
|
if ((finding.can || []).includes('change')) did.push('changed ' + rows((finding.changed || {}).change || 1));
|
|
454
464
|
if ((finding.can || []).includes('delete')) did.push('deleted ' + rows((finding.changed || {}).delete || 1));
|
|
455
465
|
const place = oneOf(finding.parent);
|
|
466
|
+
// The roles below it that were tried first and could not, when there are
|
|
467
|
+
// any: a role in the middle is named because the ones under it held.
|
|
468
|
+
const below = (finding.below || []).map((r) => '"' + r + '"');
|
|
456
469
|
return (
|
|
457
|
-
'I put one of my fake people into the other one\'s ' + place + ' as
|
|
458
|
-
'
|
|
459
|
-
listOf(did) + ' in ' + finding.table + ' for that ' + place +
|
|
460
|
-
'
|
|
461
|
-
'
|
|
462
|
-
|
|
470
|
+
'I put one of my fake people into the other one\'s ' + place + ' as ' + aRole(finding.who) +
|
|
471
|
+
' - ' + (below.length ? 'a role' : 'the lowest role') + ' in ' + finding.via + '.' + finding.roleColumn +
|
|
472
|
+
' - and, signed in as them, I ' + listOf(did) + ' in ' + finding.table + ' for that ' + place +
|
|
473
|
+
', on a copy of your app. Before joining, they could not.' +
|
|
474
|
+
(below.length ? ' As ' + listOf(below).replace(/ and /, ' or ') + ' they could not either, and no rule on this ' +
|
|
475
|
+
'table lists ' + aRole(finding.who).replace(/^an? /, '') + ' among the roles allowed to make that change.' : '') +
|
|
476
|
+
' Every one of those was undone straight away. Whether ' + aRole(finding.who) + ' may do this is your ' +
|
|
477
|
+
'decision: if it is meant to, nothing needs fixing; if not, the rules on this table let them do more.'
|
|
463
478
|
);
|
|
464
479
|
}
|
|
465
480
|
|
|
@@ -470,11 +485,11 @@ function bodyFor(finding, contents) {
|
|
|
470
485
|
const n = finding.readable || 1;
|
|
471
486
|
const below = (finding.below || []).map((r) => '"' + r + '"');
|
|
472
487
|
return (
|
|
473
|
-
'I put one of my fake people into the other one\'s ' + place + ' as
|
|
488
|
+
'I put one of my fake people into the other one\'s ' + place + ' as ' + aRole(finding.who) + ' and, signed ' +
|
|
474
489
|
'in as them, read ' + n + (n === 1 ? ' row' : ' rows') + ' of ' + finding.table + ' for that ' + place +
|
|
475
490
|
', on a copy of your app - including ' + listOf(finding.secrets || []) + '. Before joining, they could not.' +
|
|
476
491
|
(below.length ? ' As ' + listOf(below).replace(/ and /, ' or ') + ' they could not read it either.' : '') +
|
|
477
|
-
' Nothing was changed. Whether
|
|
492
|
+
' Nothing was changed. Whether ' + aRole(finding.who) + ' should see these is your decision: if not, a read ' +
|
|
478
493
|
'rule on this table lets them.'
|
|
479
494
|
);
|
|
480
495
|
}
|
|
@@ -778,16 +793,16 @@ function fixPromptFor(finding) {
|
|
|
778
793
|
finding.roleColumn + '" is "' + finding.who + '" can ' +
|
|
779
794
|
listOf((finding.can || []).map((what) => ({ add: 'add', change: 'change', delete: 'delete' })[what])) +
|
|
780
795
|
' rows of that ' + oneOf(finding.parent) + ' in the "' + finding.table + '" table. This was proved ' +
|
|
781
|
-
'on a copy of the database:
|
|
796
|
+
'on a copy of the database: ' + aRole(finding.who) + ' was added, and the writes went through.',
|
|
782
797
|
'',
|
|
783
|
-
'First tell me: should
|
|
798
|
+
'First tell me: should ' + aRole(finding.who) + ' be able to do that? If yes, change nothing.',
|
|
784
799
|
'',
|
|
785
800
|
'If not, make the rules on "' + finding.table + '" that cover UPDATE, DELETE, INSERT or ALL check ' +
|
|
786
801
|
'the member\'s role as well as membership - for example with a helper that reads "' + finding.via +
|
|
787
802
|
'" for auth.uid() and the roles allowed to edit. Postgres lets a write through if ANY permissive ' +
|
|
788
803
|
'policy allows it, so a strict rule beside a loose one does nothing: list every such policy on "' +
|
|
789
|
-
finding.table + '" and narrow or drop the loose one. Keep the read rule as it is if
|
|
790
|
-
finding.who + '
|
|
804
|
+
finding.table + '" and narrow or drop the loose one. Keep the read rule as it is if ' +
|
|
805
|
+
aRole(finding.who) + ' should still see these rows.',
|
|
791
806
|
'',
|
|
792
807
|
'Then check every other table where members can write for the same thing.',
|
|
793
808
|
]
|
|
@@ -798,9 +813,9 @@ function fixPromptFor(finding) {
|
|
|
798
813
|
'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
|
|
799
814
|
finding.roleColumn + '" is "' + finding.who + '" can read the "' + finding.table + '" table of that ' +
|
|
800
815
|
oneOf(finding.parent) + ', including ' + listOf((finding.secrets || []).map((c) => '"' + c + '"')) +
|
|
801
|
-
'. This was proved on a copy of the database:
|
|
816
|
+
'. This was proved on a copy of the database: ' + aRole(finding.who) + ' was added, and the rows came back.',
|
|
802
817
|
'',
|
|
803
|
-
'First tell me: should
|
|
818
|
+
'First tell me: should ' + aRole(finding.who) + ' see these? If yes, change nothing.',
|
|
804
819
|
'',
|
|
805
820
|
'If not, make the rules on "' + finding.table + '" that cover SELECT or ALL check the member\'s role as ' +
|
|
806
821
|
'well as membership. Postgres lets a read through if ANY permissive policy allows it, so a strict rule ' +
|
|
@@ -1049,13 +1064,13 @@ function describe(finding) {
|
|
|
1049
1064
|
: finding.kind === 'privileged'
|
|
1050
1065
|
? 'I did not call "' + finding.fn + '". This is a reach that exists in the grants, not a break I ran.'
|
|
1051
1066
|
: finding.kind === 'role'
|
|
1052
|
-
? 'As
|
|
1067
|
+
? 'As ' + aRole(finding.who) + ' who had just joined, I ' +
|
|
1053
1068
|
listOf((finding.can || []).map((what) => {
|
|
1054
1069
|
const n = (finding.changed || {})[what] || 1;
|
|
1055
1070
|
return ({ add: 'added ', change: 'changed ', delete: 'deleted ' })[what] + n + (n === 1 ? ' row' : ' rows');
|
|
1056
1071
|
})) + ' in "' + finding.table + '" that I could not touch before joining. All of it was undone.'
|
|
1057
1072
|
: finding.kind === 'teamread'
|
|
1058
|
-
? 'As
|
|
1073
|
+
? 'As ' + aRole(finding.who) + ' who had just joined, I read ' + (finding.readable || 1) +
|
|
1059
1074
|
((finding.readable || 1) === 1 ? ' row' : ' rows') + ' of "' + finding.table + '" that I could not read before joining.'
|
|
1060
1075
|
: finding.kind === 'bucket'
|
|
1061
1076
|
? 'I read that "' + finding.bucket + '" is public and that ' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
|
package/installer.js
CHANGED
|
@@ -87,6 +87,10 @@ async function install(client, options) {
|
|
|
87
87
|
const at = opts.at || AT;
|
|
88
88
|
const made = { extensions: [], schema: false, jobid: null };
|
|
89
89
|
|
|
90
|
+
// A bad project token is refused here, before anything is made, for the
|
|
91
|
+
// same reason as the extensions below.
|
|
92
|
+
if (opts.reportTo) checkReportTo(opts.reportTo);
|
|
93
|
+
|
|
90
94
|
// Both asked about before either is made. pg_net used to be created first
|
|
91
95
|
// and pg_cron checked after, so a database offering one and not the other
|
|
92
96
|
// kept a pg_net nobody recorded - and uninstall only removes what the
|
|
@@ -132,12 +136,15 @@ async function install(client, options) {
|
|
|
132
136
|
'CONSTRAINT only_one CHECK (id = 1))',
|
|
133
137
|
);
|
|
134
138
|
|
|
139
|
+
// The nightly summary, only if a project token was given.
|
|
140
|
+
const reporting = await configureReporting(client, schema, opts.reportTo || null);
|
|
141
|
+
|
|
135
142
|
// Replaced rather than added to. cron.schedule with a name that already
|
|
136
143
|
// exists updates it, but an install that ran twice under two names would
|
|
137
144
|
// attack the same app twice a night for ever.
|
|
138
145
|
const { rows } = await client.query(
|
|
139
146
|
'SELECT cron.schedule($1, $2, $3) AS jobid',
|
|
140
|
-
['kryptheon_nightly', at,
|
|
147
|
+
['kryptheon_nightly', at, jobCommand(schema, source, reporting)],
|
|
141
148
|
);
|
|
142
149
|
made.jobid = rows[0].jobid;
|
|
143
150
|
|
|
@@ -150,7 +157,60 @@ async function install(client, options) {
|
|
|
150
157
|
[source, made.extensions, made.schema, made.jobid],
|
|
151
158
|
);
|
|
152
159
|
|
|
153
|
-
return { schema: schema, source: source, at: at, jobid: made.jobid, made: made };
|
|
160
|
+
return { schema: schema, source: source, at: at, jobid: made.jobid, made: made, reporting: reporting };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** What the scheduled job runs: the night, and the summary only if one was asked for. */
|
|
164
|
+
function jobCommand(schema, source, reporting) {
|
|
165
|
+
return 'SELECT ' + quote(schema) + '.' + (reporting ? 'nightly_and_report' : 'nightly') + '(' + literal(source) + ')';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// A project token from kryptheon.tech, and the one address it may be sent to.
|
|
169
|
+
const TOKEN = /^kp_[A-Za-z0-9_-]{20,100}$/;
|
|
170
|
+
const ENDPOINT = /^https:\/\/[a-z0-9-]+(\.[a-z0-9-]+)+(:443)?\/functions\/v1\/ingest$/i;
|
|
171
|
+
|
|
172
|
+
/** Throws, in words a person can act on, unless this is a token and an address it may go to. */
|
|
173
|
+
function checkReportTo(reportTo) {
|
|
174
|
+
if (!TOKEN.test(String(reportTo.token || ''))) {
|
|
175
|
+
throw new Error('that is not a project token - copy it again from your dashboard on kryptheon.tech');
|
|
176
|
+
}
|
|
177
|
+
if (!ENDPOINT.test(String(reportTo.endpoint || ''))) {
|
|
178
|
+
throw new Error('the report address has to be an https address ending in /functions/v1/ingest');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Keeps the project token where the nightly job can read it and nobody else
|
|
184
|
+
* can, or takes it away when none is given - so an install without one sends
|
|
185
|
+
* nothing, even if an earlier install was given one.
|
|
186
|
+
*
|
|
187
|
+
* In a table of the engine's own schema, readable by its owner only: anon and
|
|
188
|
+
* authenticated - the roles a Supabase API call runs as - get nothing, so the
|
|
189
|
+
* token never reaches the app's API even if the schema were exposed.
|
|
190
|
+
*/
|
|
191
|
+
async function configureReporting(client, schema, reportTo) {
|
|
192
|
+
const table = quote(schema) + '.reporting';
|
|
193
|
+
if (!reportTo) {
|
|
194
|
+
await client.query('DROP TABLE IF EXISTS ' + table);
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
checkReportTo(reportTo);
|
|
198
|
+
await client.query(
|
|
199
|
+
'CREATE TABLE IF NOT EXISTS ' + table + ' (' +
|
|
200
|
+
'id integer PRIMARY KEY DEFAULT 1, endpoint text NOT NULL, token text NOT NULL, engine text, ' +
|
|
201
|
+
'CONSTRAINT only_one CHECK (id = 1))',
|
|
202
|
+
);
|
|
203
|
+
await client.query('REVOKE ALL ON ' + table + ' FROM PUBLIC');
|
|
204
|
+
for (const role of ['anon', 'authenticated']) {
|
|
205
|
+
const { rows } = await client.query('SELECT 1 FROM pg_roles WHERE rolname = $1', [role]);
|
|
206
|
+
if (rows.length) await client.query('REVOKE ALL ON ' + table + ' FROM ' + role);
|
|
207
|
+
}
|
|
208
|
+
await client.query(
|
|
209
|
+
'INSERT INTO ' + table + ' (id, endpoint, token, engine) VALUES (1, $1, $2, $3) ' +
|
|
210
|
+
'ON CONFLICT (id) DO UPDATE SET endpoint = EXCLUDED.endpoint, token = EXCLUDED.token, engine = EXCLUDED.engine',
|
|
211
|
+
[reportTo.endpoint, reportTo.token, reportTo.engine || null],
|
|
212
|
+
);
|
|
213
|
+
return { endpoint: reportTo.endpoint };
|
|
154
214
|
}
|
|
155
215
|
|
|
156
216
|
/** A string, quoted the way Postgres wants it inside another statement. */
|
|
@@ -252,4 +312,6 @@ module.exports = {
|
|
|
252
312
|
uninstall: uninstall,
|
|
253
313
|
status: status,
|
|
254
314
|
extensionState: extensionState,
|
|
315
|
+
configureReporting: configureReporting,
|
|
316
|
+
jobCommand: jobCommand,
|
|
255
317
|
};
|
package/intro.js
CHANGED
|
@@ -123,7 +123,21 @@ function consentLines(target) {
|
|
|
123
123
|
* recognise. And the way out is on the screen where they say yes, not in a
|
|
124
124
|
* README they will not read again.
|
|
125
125
|
*/
|
|
126
|
-
function installConsentLines(schema, source, at) {
|
|
126
|
+
function installConsentLines(schema, source, at, reporting) {
|
|
127
|
+
// Said as it is: with a project token the night does send something, so
|
|
128
|
+
// "sends nothing anywhere" would be a lie on exactly the screen that asks.
|
|
129
|
+
const sends = reporting
|
|
130
|
+
? [
|
|
131
|
+
' - It still never reads your real data. Once a night it sends your',
|
|
132
|
+
' Kryptheon dashboard a summary of what it found: the kind of each',
|
|
133
|
+
' finding and the table it is on. Never a row, never a value, never',
|
|
134
|
+
' your connection string. That is what your morning report and',
|
|
135
|
+
' alerts are made from.',
|
|
136
|
+
]
|
|
137
|
+
: [
|
|
138
|
+
' - It still never reads your real data, and still sends nothing anywhere.',
|
|
139
|
+
' Nothing leaves your database, because the checking happens inside it.',
|
|
140
|
+
];
|
|
127
141
|
return [
|
|
128
142
|
'This one stays. Everything else this tool does is temporary; this is not.',
|
|
129
143
|
'',
|
|
@@ -143,8 +157,7 @@ function installConsentLines(schema, source, at) {
|
|
|
143
157
|
' tables into a temporary space, put two made-up people in it, attack',
|
|
144
158
|
' that, write down what got through, and delete the copy.',
|
|
145
159
|
'',
|
|
146
|
-
|
|
147
|
-
' Nothing leaves your database, because the checking happens inside it.',
|
|
160
|
+
...sends,
|
|
148
161
|
'',
|
|
149
162
|
'One thing it cannot do:',
|
|
150
163
|
'',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kryptheon-night",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.30",
|
|
4
4
|
"description": "Attacks a copy of your Supabase database and tells you in plain English what got in.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -31,13 +31,14 @@
|
|
|
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",
|
|
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",
|
|
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",
|
|
38
38
|
"check:trouble": "node trouble.check.js",
|
|
39
39
|
"check:intro": "node intro.check.js",
|
|
40
40
|
"check:report": "node finding.check.js",
|
|
41
|
+
"check:nightly-report": "node report.check.js",
|
|
41
42
|
"check:outside": "node outside.check.js",
|
|
42
43
|
"check:recheck": "node recheck.check.js",
|
|
43
44
|
"check:copy": "node schema.check.js",
|
package/scan.js
CHANGED
|
@@ -153,7 +153,7 @@ function nodeEngine() {
|
|
|
153
153
|
tamper: (client, into, tables, seeded, policies) =>
|
|
154
154
|
tamper.tamper(client, into, tables, seeded, policies),
|
|
155
155
|
orphan: (client, into, tables, seeded) => orphan.orphan(client, into, tables, seeded),
|
|
156
|
-
teammate: (client, into, tables, seeded) => tamper.teammate(client, into, tables, seeded),
|
|
156
|
+
teammate: (client, into, tables, seeded, rules) => tamper.teammate(client, into, tables, seeded, rules),
|
|
157
157
|
};
|
|
158
158
|
}
|
|
159
159
|
|
|
@@ -305,7 +305,10 @@ async function scan(client, sourceSchema, options) {
|
|
|
305
305
|
|
|
306
306
|
// What the least trusted member of a team can change there. Rolled back
|
|
307
307
|
// like the writes above, and the joining with them.
|
|
308
|
-
|
|
308
|
+
// With the copy's rules and helpers, so a role a rule lists as allowed to
|
|
309
|
+
// write is not reported (tamper.allowedByRules).
|
|
310
|
+
const ranked = await engine.teammate(client, copyName, theirs, sown.seeded,
|
|
311
|
+
{ policies: copyPlan.policies, functions: copyPlan.functions });
|
|
309
312
|
for (const key of ranked.completed) attempted.push(key);
|
|
310
313
|
for (const stuck of ranked.blocked) {
|
|
311
314
|
notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
|
package/sqlengine.js
CHANGED
|
@@ -115,10 +115,10 @@ async function tamper(client, target, into, tables, seeded, policies) {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
/** What the least trusted member of a team can change. Rolled back inside the engine. */
|
|
118
|
-
async function teammate(client, target, into, tables, seeded) {
|
|
118
|
+
async function teammate(client, target, into, tables, seeded, rules) {
|
|
119
119
|
const { rows } = await client.query(
|
|
120
|
-
'SELECT ' + quote(target) + '.teammate($1, $2::jsonb, $3::jsonb) AS answer',
|
|
121
|
-
[into, JSON.stringify(tables), JSON.stringify(seeded)],
|
|
120
|
+
'SELECT ' + quote(target) + '.teammate($1, $2::jsonb, $3::jsonb, $4::jsonb) AS answer',
|
|
121
|
+
[into, JSON.stringify(tables), JSON.stringify(seeded), JSON.stringify(rules || {})],
|
|
122
122
|
);
|
|
123
123
|
return rows[0].answer;
|
|
124
124
|
}
|
|
@@ -171,7 +171,7 @@ function adapterFor(target) {
|
|
|
171
171
|
tamper: (client, into, tables, sown, policies) =>
|
|
172
172
|
tamper(client, target, into, tables, sown, policies),
|
|
173
173
|
orphan: (client, into, tables, sown) => orphan(client, target, into, tables, sown),
|
|
174
|
-
teammate: (client, into, tables, sown) => teammate(client, target, into, tables, sown),
|
|
174
|
+
teammate: (client, into, tables, sown, rules) => teammate(client, target, into, tables, sown, rules),
|
|
175
175
|
};
|
|
176
176
|
}
|
|
177
177
|
|
package/tamper.js
CHANGED
|
@@ -289,7 +289,7 @@ function roleColumnOf(table) {
|
|
|
289
289
|
// the order reads are tried in, so the least trusted one that can read a
|
|
290
290
|
// secret is the one named.
|
|
291
291
|
const ladder = [lowest].concat(labels.filter((label) => label !== lowest && !IN_CHARGE.test(label)).reverse());
|
|
292
|
-
if (lowest) return { column: column.name, lowest: lowest, ladder: ladder };
|
|
292
|
+
if (lowest) return { column: column.name, type: column.type, lowest: lowest, ladder: ladder };
|
|
293
293
|
}
|
|
294
294
|
return null;
|
|
295
295
|
}
|
|
@@ -325,13 +325,51 @@ function memberships(tables) {
|
|
|
325
325
|
const keys = attack.foreignKeys(table).concat(attack.impliedKeys(table, tables))
|
|
326
326
|
.filter((key) => key.refTable !== table.name && names.has(key.refTable) && !key.columns.includes(person));
|
|
327
327
|
if (!keys.length || aloneUnique(table, person)) continue;
|
|
328
|
-
found.push({ table: table, person: person, role: role.column,
|
|
328
|
+
found.push({ table: table, person: person, role: role.column, roleType: role.type, lowest: role.lowest,
|
|
329
|
+
ladder: role.ladder, keys: keys });
|
|
329
330
|
}
|
|
330
331
|
// A table named for members first: it is the one a team rule asks.
|
|
331
332
|
const named = (m) => (/member/i.test(m.table.name) ? 0 : 1);
|
|
332
333
|
return found.map((m, i) => [m, i]).sort((a, b) => named(a[0]) - named(b[0]) || a[1] - b[1]).map((pair) => pair[0]);
|
|
333
334
|
}
|
|
334
335
|
|
|
336
|
+
// What each kind of write is called in a rule.
|
|
337
|
+
const COMMAND = { add: 'INSERT', change: 'UPDATE', delete: 'DELETE' };
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Whether the app's own rules name this role among those allowed to make
|
|
341
|
+
* this kind of write - the way a repository's role rule does it: the role in
|
|
342
|
+
* a list in a permissive rule for that command, has_role(team,
|
|
343
|
+
* ARRAY['owner', 'maintainer', 'developer']), or in a list inside a helper
|
|
344
|
+
* that rule calls (role IN ('owner', 'developer')). A rule written for one
|
|
345
|
+
* role on its own - role = 'analyst', the shape production added in
|
|
346
|
+
* LaunchRail - is not a list of who may write, and stays something to check.
|
|
347
|
+
*/
|
|
348
|
+
function allowedByRules(rules, table, what, role, type) {
|
|
349
|
+
const escape = (x) => String(x).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
350
|
+
const label = "'" + escape(String(role).split("'").join("''")) + "'";
|
|
351
|
+
const typeName = escape(String(type || 'text').split('.').pop().split('"').join(''));
|
|
352
|
+
const inRuleList = new RegExp('ARRAY\\[[^\\]]*' + label + '::("?[A-Za-z0-9_$]+"?\\.)?"?' + typeName + '"?', 'i');
|
|
353
|
+
// The same list written as an array literal - has_role(team,
|
|
354
|
+
// '{owner,admin,member}') - which Postgres keeps as '{...}'::role[].
|
|
355
|
+
const bare = escape(String(role));
|
|
356
|
+
const inRuleLiteral = new RegExp("'\\{([^}']*,)?\"?" + bare + "\"?(,[^}']*)?\\}'::(\"?[A-Za-z0-9_$]+\"?\\.)?\"?" +
|
|
357
|
+
typeName + '"?\\[\\]', 'i');
|
|
358
|
+
const inHelperList = new RegExp('(\\bIN\\s*\\(|ARRAY\\[)[^)\\]]*' + label, 'i');
|
|
359
|
+
const calls = (text, name) => new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?\\s*\\(').test(text);
|
|
360
|
+
const functions = (rules && rules.functions) || [];
|
|
361
|
+
for (const policy of (rules && rules.policies) || []) {
|
|
362
|
+
if (policy.table_name !== table) continue;
|
|
363
|
+
if (String(policy.permissive || 'PERMISSIVE').toUpperCase() !== 'PERMISSIVE') continue;
|
|
364
|
+
const cmd = String(policy.cmd || 'ALL').toUpperCase();
|
|
365
|
+
if (cmd !== 'ALL' && cmd !== COMMAND[what]) continue;
|
|
366
|
+
const text = String(policy.qual || '') + ' ' + String(policy.with_check || '');
|
|
367
|
+
if (inRuleList.test(text) || inRuleLiteral.test(text)) return true;
|
|
368
|
+
if (functions.some((fn) => calls(text, fn.name) && inHelperList.test(String(fn.src || '')))) return true;
|
|
369
|
+
}
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
|
|
335
373
|
/**
|
|
336
374
|
* For every team table: can a viewer change it?
|
|
337
375
|
*
|
|
@@ -341,7 +379,7 @@ function memberships(tables) {
|
|
|
341
379
|
* and a person changing their own rows is the feature. Both rolled back, the
|
|
342
380
|
* joining with them.
|
|
343
381
|
*/
|
|
344
|
-
async function teammate(client, schema, tables, seeded) {
|
|
382
|
+
async function teammate(client, schema, tables, seeded, rules) {
|
|
345
383
|
const findings = [];
|
|
346
384
|
const completed = [];
|
|
347
385
|
const blocked = [];
|
|
@@ -413,7 +451,6 @@ async function teammate(client, schema, tables, seeded) {
|
|
|
413
451
|
for (const table of tables) {
|
|
414
452
|
if (!shapeFor.has(table.name) || done.has(table.name)) continue;
|
|
415
453
|
done.add(table.name);
|
|
416
|
-
const key = 'role:' + table.name + ':' + member.lowest;
|
|
417
454
|
const owner = attack.ownerColumn(table);
|
|
418
455
|
// A table that names the team itself, and the team it is to name.
|
|
419
456
|
const toTeam = attack.foreignKeys(table).concat(attack.impliedKeys(table, tables))
|
|
@@ -448,11 +485,12 @@ async function teammate(client, schema, tables, seeded) {
|
|
|
448
485
|
// under the other person's name: a rule that lets a member add a row
|
|
449
486
|
// only as themselves - a note they wrote - is a member speaking for
|
|
450
487
|
// themselves, and refuses it (OrbitDesk's internal notes, by design).
|
|
451
|
-
if (
|
|
488
|
+
if (toTeam && member.ladder.some(lookOnly)) {
|
|
452
489
|
try {
|
|
453
490
|
const row = await attack.rowFor(client, schema, table, attack.USER_A, 11, pointed, shapeFor.get(table.name));
|
|
454
491
|
moves.unshift({
|
|
455
492
|
what: 'add',
|
|
493
|
+
lookOnly: true,
|
|
456
494
|
statement: row.columns.length
|
|
457
495
|
? 'INSERT INTO ' + target + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' +
|
|
458
496
|
row.values.map((_, i) => '$' + (i + 1)).join(', ') + ')'
|
|
@@ -463,39 +501,75 @@ async function teammate(client, schema, tables, seeded) {
|
|
|
463
501
|
// Nothing to point a new row at: there is no row to add.
|
|
464
502
|
}
|
|
465
503
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
504
|
+
|
|
505
|
+
// Every role not in charge, from the lowest up, the way reads are tried:
|
|
506
|
+
// found on a blind test (LaunchRail), where production gave the
|
|
507
|
+
// "analyst" role its own UPDATE rule on feature flags and only the
|
|
508
|
+
// "viewer" below it was ever tried. The first role that can write where
|
|
509
|
+
// the app's own rules do not list it is the one named; a role a rule
|
|
510
|
+
// lists among those allowed to write is the design, and the roles above
|
|
511
|
+
// it are still tried. What each write did before joining is asked once.
|
|
512
|
+
const beforeJoining = new Map();
|
|
469
513
|
for (const move of moves) {
|
|
470
|
-
|
|
471
|
-
const after = await tryWrite(client, 'authenticated', attack.USER_B, move.statement, move.values, join);
|
|
472
|
-
const was = counted(before);
|
|
473
|
-
const now = counted(after);
|
|
474
|
-
if (was === null || now === null) {
|
|
475
|
-
stuck = now === null ? after.why : before.why;
|
|
476
|
-
} else if (now > was) {
|
|
477
|
-
can.push(move.what);
|
|
478
|
-
changed[move.what] = now - was;
|
|
479
|
-
}
|
|
514
|
+
beforeJoining.set(move.what, await tryWrite(client, 'authenticated', attack.USER_B, move.statement, move.values));
|
|
480
515
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
516
|
+
const below = [];
|
|
517
|
+
for (const role of member.ladder) {
|
|
518
|
+
const key = 'role:' + table.name + ':' + role;
|
|
519
|
+
let joined;
|
|
520
|
+
try {
|
|
521
|
+
joined = await joinAs(role);
|
|
522
|
+
} catch (err) {
|
|
523
|
+
blocked.push({ table: table.name, key: key, why: as(role) + ': I could not add one: ' + err.message });
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
const can = [];
|
|
527
|
+
const changed = {};
|
|
528
|
+
let stuck = null;
|
|
529
|
+
// Whether this role could write where the rules list it: then it is
|
|
530
|
+
// not one that "could not", whatever is said of the roles above it.
|
|
531
|
+
let listed = false;
|
|
532
|
+
for (const move of moves) {
|
|
533
|
+
if (move.lookOnly && !lookOnly(role)) continue;
|
|
534
|
+
// A write the rules list this role for is not asked: whatever it
|
|
535
|
+
// did would not be reported, and a key refusing it on the way out
|
|
536
|
+
// left the table untested (HelixOps' assets, as a dispatcher).
|
|
537
|
+
if (allowedByRules(rules, table.name, move.what, role, member.roleType)) {
|
|
538
|
+
listed = true;
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
const after = await tryWrite(client, 'authenticated', attack.USER_B, move.statement, move.values, joined);
|
|
542
|
+
const was = counted(beforeJoining.get(move.what));
|
|
543
|
+
const now = counted(after);
|
|
544
|
+
if (was === null || now === null) {
|
|
545
|
+
stuck = now === null ? after.why : beforeJoining.get(move.what).why;
|
|
546
|
+
} else if (now > was) {
|
|
547
|
+
can.push(move.what);
|
|
548
|
+
changed[move.what] = now - was;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
if (!can.length && stuck) {
|
|
552
|
+
blocked.push({ table: table.name, key: key, why: as(role) + ': ' + stuck });
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
484
555
|
completed.push(key);
|
|
485
556
|
if (can.length) {
|
|
486
557
|
findings.push({
|
|
487
558
|
kind: 'role',
|
|
488
559
|
table: table.name,
|
|
489
|
-
who:
|
|
560
|
+
who: role,
|
|
490
561
|
via: member.table.name,
|
|
491
562
|
roleColumn: member.role,
|
|
492
563
|
parent: parent,
|
|
493
564
|
can: can,
|
|
494
565
|
changed: changed,
|
|
566
|
+
below: below.slice(),
|
|
495
567
|
columns: (table.columns || []).map((c) => c.name),
|
|
496
568
|
rlsEnabled: table.rlsEnabled,
|
|
497
569
|
});
|
|
570
|
+
break;
|
|
498
571
|
}
|
|
572
|
+
if (!listed) below.push(role);
|
|
499
573
|
}
|
|
500
574
|
|
|
501
575
|
// Reading what a member should not see, said only of a table holding a
|
|
@@ -552,6 +626,7 @@ module.exports = {
|
|
|
552
626
|
writeAsFor: writeAsFor,
|
|
553
627
|
tamper: tamper,
|
|
554
628
|
lowestRole: lowestRole,
|
|
629
|
+
allowedByRules: allowedByRules,
|
|
555
630
|
secretColumns: secretColumns,
|
|
556
631
|
lookOnly: lookOnly,
|
|
557
632
|
roleColumnOf: roleColumnOf,
|