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 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.36",
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
- lines.push(' ' + missed.table + ' - ' + missed.why);
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).