kryptheon-night 0.1.36 → 0.1.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/engine.sql +34 -0
- package/package.json +4 -3
- package/scan.js +51 -3
- package/schema.js +5 -0
package/engine.sql
CHANGED
|
@@ -261,6 +261,9 @@ RETURNS jsonb LANGUAGE sql STABLE AS $$
|
|
|
261
261
|
'def', pg_get_functiondef(p.oid),
|
|
262
262
|
'args', pg_get_function_identity_arguments(p.oid),
|
|
263
263
|
'default_acl', p.proacl IS NULL,
|
|
264
|
+
-- Installed by an extension, not written by the app (schema.js readFunctions).
|
|
265
|
+
'from_extension', EXISTS (SELECT 1 FROM pg_depend d
|
|
266
|
+
WHERE d.classid = 'pg_proc'::regclass AND d.objid = p.oid AND d.deptype = 'e'),
|
|
264
267
|
-- Whether the app can call it at all (schema.js readFunctions).
|
|
265
268
|
'client_callable', CASE WHEN EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon')
|
|
266
269
|
AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated')
|
|
@@ -3954,6 +3957,35 @@ BEGIN
|
|
|
3954
3957
|
RETURN out_;
|
|
3955
3958
|
END $$;
|
|
3956
3959
|
|
|
3960
|
+
/* The tables whose rules decide who is asking from a setting the app's own
|
|
3961
|
+
server sets - current_setting('app.tenant_id') - not from a Supabase
|
|
3962
|
+
sign-in: NOT checked, said with the settings (scan.js serverIdentityOf,
|
|
3963
|
+
blind round 13). */
|
|
3964
|
+
CREATE OR REPLACE FUNCTION __KN__.server_identity(plan jsonb)
|
|
3965
|
+
RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
|
|
3966
|
+
WITH texts AS (
|
|
3967
|
+
SELECT p->>'table_name' AS t, coalesce(p->>'qual', '') || ' ' || coalesce(p->>'with_check', '') AS x
|
|
3968
|
+
FROM jsonb_array_elements(coalesce(plan->'policies', '[]'::jsonb)) p
|
|
3969
|
+
), settings AS (
|
|
3970
|
+
SELECT t, (regexp_matches(x, 'current_setting\s*\(\s*''([^'']+)''', 'gi'))[1] AS s
|
|
3971
|
+
FROM texts
|
|
3972
|
+
WHERE x !~* '\mauth\s*\.\s*(uid|jwt|role|email)\s*\(|request\.jwt'
|
|
3973
|
+
), per AS (
|
|
3974
|
+
SELECT t, array_agg(DISTINCT 'current_setting(''' || s || ''')' ORDER BY 'current_setting(''' || s || ''')') AS q
|
|
3975
|
+
FROM settings
|
|
3976
|
+
WHERE s !~* '^request\.'
|
|
3977
|
+
GROUP BY t
|
|
3978
|
+
)
|
|
3979
|
+
SELECT coalesce(jsonb_agg(jsonb_build_object(
|
|
3980
|
+
'table', t,
|
|
3981
|
+
'why', 'its rules decide who is asking with '
|
|
3982
|
+
|| CASE WHEN cardinality(q) < 2 THEN q[1]
|
|
3983
|
+
ELSE array_to_string(q[1:cardinality(q) - 1], ', ') || ' and ' || q[cardinality(q)] END,
|
|
3984
|
+
'identity', true)
|
|
3985
|
+
ORDER BY t COLLATE "C"), '[]'::jsonb)
|
|
3986
|
+
FROM per;
|
|
3987
|
+
$$;
|
|
3988
|
+
|
|
3957
3989
|
/* The app's functions read and never called (scan.js notCalledOf). */
|
|
3958
3990
|
CREATE OR REPLACE FUNCTION __KN__.not_called(plan jsonb, findings jsonb)
|
|
3959
3991
|
RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
|
|
@@ -3966,6 +3998,7 @@ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
|
|
|
3966
3998
|
SELECT coalesce(jsonb_agg(DISTINCT (fn->>'name') COLLATE "C" ORDER BY (fn->>'name') COLLATE "C"), '[]'::jsonb)
|
|
3967
3999
|
FROM jsonb_array_elements(coalesce(plan->'functions', '[]'::jsonb)) fn, rules
|
|
3968
4000
|
WHERE coalesce((fn->>'client_callable')::boolean, false)
|
|
4001
|
+
AND NOT coalesce((fn->>'from_extension')::boolean, false)
|
|
3969
4002
|
AND (fn->>'name') NOT IN (SELECT name FROM named)
|
|
3970
4003
|
AND NOT __KN__.calls_function(rules.text_, fn->>'name');
|
|
3971
4004
|
$$;
|
|
@@ -4121,6 +4154,7 @@ BEGIN
|
|
|
4121
4154
|
-- And everything that was not. Said out loud, never left out: a table
|
|
4122
4155
|
-- nothing could be put into reads exactly like a table nothing got out of.
|
|
4123
4156
|
not_checked := coalesce(sown->'skipped', '[]'::jsonb)
|
|
4157
|
+
|| __KN__.server_identity(plan)
|
|
4124
4158
|
|| coalesce(seen->'blocked', '[]'::jsonb)
|
|
4125
4159
|
|| coalesce(wrote->'blocked', '[]'::jsonb)
|
|
4126
4160
|
|| coalesce(ranked->'blocked', '[]'::jsonb)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kryptheon-night",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.37",
|
|
4
4
|
"description": "Attacks a copy of your Supabase database and tells you in plain English what got in.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"node": ">=18.0.0"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
-
"check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node recursion.check.js && node guarded.check.js && node nightly.check.js && node words.check.js && node helpers.check.js && node deeploop.check.js && node teams.check.js && node roles.check.js && node buckets.check.js && node cli.check.js && node loop.check.js && node report.check.js && node installscript.check.js && node tenants.check.js && node takeover.check.js && node checkfit.check.js && node declared.check.js",
|
|
34
|
+
"check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node recursion.check.js && node guarded.check.js && node nightly.check.js && node words.check.js && node helpers.check.js && node deeploop.check.js && node teams.check.js && node roles.check.js && node buckets.check.js && node cli.check.js && node loop.check.js && node report.check.js && node installscript.check.js && node tenants.check.js && node takeover.check.js && node checkfit.check.js && node declared.check.js && node identity.check.js",
|
|
35
35
|
"check:dry": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node recheck.check.js && node outside.check.js",
|
|
36
36
|
"check:package": "node package.check.js",
|
|
37
37
|
"check:store": "node store.check.js",
|
|
@@ -78,7 +78,8 @@
|
|
|
78
78
|
"check:installscript": "node installscript.check.js",
|
|
79
79
|
"check:tenants": "node tenants.check.js",
|
|
80
80
|
"check:takeover": "node takeover.check.js",
|
|
81
|
-
"check:checkfit": "node checkfit.check.js"
|
|
81
|
+
"check:checkfit": "node checkfit.check.js",
|
|
82
|
+
"check:identity": "node identity.check.js"
|
|
82
83
|
},
|
|
83
84
|
"keywords": [
|
|
84
85
|
"supabase",
|
package/scan.js
CHANGED
|
@@ -285,7 +285,7 @@ async function scan(client, sourceSchema, options) {
|
|
|
285
285
|
// Surfaced, not swallowed. A table with no row in it reads as a safe
|
|
286
286
|
// table, so the one thing that must never happen is reporting an app as
|
|
287
287
|
// clear when part of it was never actually tried.
|
|
288
|
-
const notChecked = sown.skipped.slice();
|
|
288
|
+
const notChecked = sown.skipped.slice().concat(serverIdentityOf(plan));
|
|
289
289
|
|
|
290
290
|
// A read that errored for any reason other than the table being closed to
|
|
291
291
|
// that role decided nothing at all, and zero rows back from a failed query
|
|
@@ -693,6 +693,44 @@ async function conflictsIn(client, into, plan, indexes) {
|
|
|
693
693
|
return found;
|
|
694
694
|
}
|
|
695
695
|
|
|
696
|
+
/**
|
|
697
|
+
* The tables whose rules decide who is asking from a setting the app's own
|
|
698
|
+
* server sets - current_setting('app.tenant_id') - rather than from a
|
|
699
|
+
* Supabase sign-in. Each is a table NOT checked, said with the settings.
|
|
700
|
+
*
|
|
701
|
+
* Found on blind round 13 (Northstar Ops, Express on plain Postgres): every
|
|
702
|
+
* rule read app.tenant_id / app.user_id, which nothing here sets. Every attack
|
|
703
|
+
* ran as nobody, every one was refused for that reason alone, and the report
|
|
704
|
+
* said "Nothing got through" over a table whose rule was USING (true) - the
|
|
705
|
+
* one sentence this program must never print by accident. A rule that also
|
|
706
|
+
* asks Supabase (auth.uid(), request.jwt...) is attacked as usual.
|
|
707
|
+
* Mirrors server_identity in engine.sql.
|
|
708
|
+
*/
|
|
709
|
+
function serverIdentityOf(plan) {
|
|
710
|
+
const byTable = new Map();
|
|
711
|
+
for (const p of (plan && plan.policies) || []) {
|
|
712
|
+
const text = String(p.qual || '') + ' ' + String(p.with_check || '');
|
|
713
|
+
if (/\bauth\s*\.\s*(uid|jwt|role|email)\s*\(|request\.jwt/i.test(text)) continue;
|
|
714
|
+
const re = /current_setting\s*\(\s*'([^']+)'/gi;
|
|
715
|
+
let m;
|
|
716
|
+
while ((m = re.exec(text))) {
|
|
717
|
+
if (/^request\./i.test(m[1])) continue;
|
|
718
|
+
if (!byTable.has(p.table_name)) byTable.set(p.table_name, new Set());
|
|
719
|
+
byTable.get(p.table_name).add(m[1]);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return Array.from(byTable.keys()).sort().map((table) => {
|
|
723
|
+
const settings = Array.from(byTable.get(table)).sort();
|
|
724
|
+
// The why is short and per table; what it means is said once, under the
|
|
725
|
+
// list (notTestedLines), not repeated for every table.
|
|
726
|
+
return {
|
|
727
|
+
table: table,
|
|
728
|
+
why: 'its rules decide who is asking with ' + andList(settings.map((s) => "current_setting('" + s + "')")),
|
|
729
|
+
identity: true,
|
|
730
|
+
};
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
696
734
|
/**
|
|
697
735
|
* The app's functions that were read and never called: anything a visitor or
|
|
698
736
|
* a signed-in user can call, that no rule calls for them (those ran with every
|
|
@@ -707,7 +745,7 @@ function notCalledOf(plan, findings) {
|
|
|
707
745
|
const rules = ((plan && plan.policies) || []).map((p) => String(p.qual || '') + ' ' + String(p.with_check || '')).join(' ');
|
|
708
746
|
const named = new Set((findings || []).map((f) => f.fn).filter(Boolean));
|
|
709
747
|
const names = ((plan && plan.functions) || [])
|
|
710
|
-
.filter((fn) => fn.client_callable === true && !named.has(fn.name))
|
|
748
|
+
.filter((fn) => fn.client_callable === true && fn.from_extension !== true && !named.has(fn.name))
|
|
711
749
|
.map((fn) => fn.name)
|
|
712
750
|
.filter((name) => !new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?\\s*\\(').test(rules));
|
|
713
751
|
return Array.from(new Set(names)).sort();
|
|
@@ -915,8 +953,17 @@ function notTestedLines(result) {
|
|
|
915
953
|
lines.push(' Every table I could reach was attacked, and every attack I have');
|
|
916
954
|
lines.push(' finished, ran.');
|
|
917
955
|
} else {
|
|
956
|
+
// Wrapped: a reason can be a sentence or two (serverIdentityOf), and an
|
|
957
|
+
// unwrapped one ran off the side of the terminal and the dashboard.
|
|
918
958
|
for (const missed of notChecked) {
|
|
919
|
-
|
|
959
|
+
wrap(missed.table + ' - ' + missed.why, 66).forEach((l, i) => lines.push((i ? ' ' : ' ') + l));
|
|
960
|
+
}
|
|
961
|
+
if (notChecked.some((missed) => missed.identity)) {
|
|
962
|
+
lines.push('');
|
|
963
|
+
wrap('Those settings are set by your own server, not by a Supabase sign-in. I sign in the way Supabase ' +
|
|
964
|
+
'does, so every attack on those tables ran as nobody, and being refused proves nothing about them. ' +
|
|
965
|
+
'Check those rules by hand, or test them with your server setting who is asking.', 66)
|
|
966
|
+
.forEach((l) => lines.push(' ' + l));
|
|
920
967
|
}
|
|
921
968
|
lines.push('');
|
|
922
969
|
lines.push(' These are unknown, not clear. An attack that never ran comes back');
|
|
@@ -1201,6 +1248,7 @@ module.exports = {
|
|
|
1201
1248
|
partialWhereFor: partialWhereFor,
|
|
1202
1249
|
conflictsIn: conflictsIn,
|
|
1203
1250
|
notCalledOf: notCalledOf,
|
|
1251
|
+
serverIdentityOf: serverIdentityOf,
|
|
1204
1252
|
bucketsOf: bucketsOf,
|
|
1205
1253
|
loopingTables: loopingTables,
|
|
1206
1254
|
taggerFor: taggerFor,
|
package/schema.js
CHANGED
|
@@ -387,6 +387,11 @@ async function readFunctions(client, schema) {
|
|
|
387
387
|
pg_get_functiondef(p.oid) AS def,
|
|
388
388
|
pg_get_function_identity_arguments(p.oid) AS args,
|
|
389
389
|
p.proacl IS NULL AS default_acl,
|
|
390
|
+
-- Installed by an extension (pgcrypto's crypt(), digest() ...):
|
|
391
|
+
-- not something the app wrote, so never listed as "your app's
|
|
392
|
+
-- functions" (blind round 13 listed 23 of pgcrypto's).
|
|
393
|
+
EXISTS (SELECT 1 FROM pg_depend d
|
|
394
|
+
WHERE d.classid = 'pg_proc'::regclass AND d.objid = p.oid AND d.deptype = 'e') AS from_extension,
|
|
390
395
|
-- Whether the app can call it at all, as a visitor or signed in.
|
|
391
396
|
-- Such a function is something the app does that was read and
|
|
392
397
|
-- never run - said under "What I did not test" (scan.js notCalledOf).
|