kryptheon-night 0.1.10 → 0.1.14
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/attack.js +33 -1
- package/engine.sql +44 -1
- package/finding.js +140 -11
- package/package.json +4 -2
- package/recheck.js +9 -1
- package/scan.js +27 -2
- package/schema.js +75 -1
package/attack.js
CHANGED
|
@@ -502,6 +502,22 @@ function refusalMeans(message) {
|
|
|
502
502
|
return denied.test(String(message)) ? 'unreachable' : 'untested';
|
|
503
503
|
}
|
|
504
504
|
|
|
505
|
+
/**
|
|
506
|
+
* The table whose rule looks itself up, or null.
|
|
507
|
+
*
|
|
508
|
+
* A rule on organization_members that asks organization_members who is a
|
|
509
|
+
* member has to be checked to be checked, and Postgres stops the request
|
|
510
|
+
* rather than loop. Found on a blind test: every signed-in read touching that
|
|
511
|
+
* table failed, the app was broken for everyone with an account, and the
|
|
512
|
+
* report listed it only as a reason something was not tested. It is a finding
|
|
513
|
+
* in its own right - and it names the table at fault, which is often not the
|
|
514
|
+
* one that was being read.
|
|
515
|
+
*/
|
|
516
|
+
function recursionIn(message) {
|
|
517
|
+
const found = /infinite recursion detected in policy for relation "([^"]+)"/i.exec(String(message || ''));
|
|
518
|
+
return found ? found[1] : null;
|
|
519
|
+
}
|
|
520
|
+
|
|
505
521
|
/**
|
|
506
522
|
* What each table gives away, and to whom.
|
|
507
523
|
*
|
|
@@ -517,6 +533,8 @@ async function impersonate(client, schema, tables) {
|
|
|
517
533
|
const findings = [];
|
|
518
534
|
const completed = [];
|
|
519
535
|
const blocked = [];
|
|
536
|
+
// Rules that looked themselves up, and which read ran into each one.
|
|
537
|
+
const looped = [];
|
|
520
538
|
|
|
521
539
|
/** Did this read produce a verdict, and if not, why not? */
|
|
522
540
|
const settle = (key, table, answer, as) => {
|
|
@@ -539,6 +557,19 @@ async function impersonate(client, schema, tables) {
|
|
|
539
557
|
const anon = await readAs(client, schema, table.name, 'anon', null);
|
|
540
558
|
const asA = await readAs(client, schema, table.name, 'authenticated', USER_A);
|
|
541
559
|
|
|
560
|
+
// Looked for on every table, owner or not: a signed-in read of a table
|
|
561
|
+
// with nobody's name on it fails just the same, and it is the same
|
|
562
|
+
// broken page.
|
|
563
|
+
for (const [answer, who] of [[anon, 'anyone'], [asA, 'signed-in']]) {
|
|
564
|
+
const relation = !Array.isArray(answer) && recursionIn(answer.blocked);
|
|
565
|
+
if (relation) looped.push({ relation: relation, table: table.name, who: who });
|
|
566
|
+
}
|
|
567
|
+
// Only a read of the table itself that came back with a verdict, for both
|
|
568
|
+
// callers, shows its rules no longer loop. A read that failed for some
|
|
569
|
+
// other reason shows nothing either way.
|
|
570
|
+
const answered = (answer) => Array.isArray(answer) || refusalMeans(answer.blocked) === 'unreachable';
|
|
571
|
+
if (answered(anon) && answered(asA)) completed.push('recursive:' + table.name);
|
|
572
|
+
|
|
542
573
|
if (settle('exposed:' + table.name, table.name, anon, 'as a logged-out visitor') && anon.length > 0) {
|
|
543
574
|
findings.push({
|
|
544
575
|
kind: 'exposed',
|
|
@@ -567,7 +598,7 @@ async function impersonate(client, schema, tables) {
|
|
|
567
598
|
}
|
|
568
599
|
}
|
|
569
600
|
|
|
570
|
-
return { findings: findings, completed: completed, blocked: blocked };
|
|
601
|
+
return { findings: findings, completed: completed, blocked: blocked, looped: looped };
|
|
571
602
|
}
|
|
572
603
|
|
|
573
604
|
/** A stable shape for comparing one run against another. */
|
|
@@ -585,6 +616,7 @@ module.exports = {
|
|
|
585
616
|
USER_C: USER_C,
|
|
586
617
|
USER_D: USER_D,
|
|
587
618
|
refusalMeans: refusalMeans,
|
|
619
|
+
recursionIn: recursionIn,
|
|
588
620
|
ownerColumn: ownerColumn,
|
|
589
621
|
fitTo: fitTo,
|
|
590
622
|
foreignKeys: foreignKeys,
|
package/engine.sql
CHANGED
|
@@ -1613,6 +1613,17 @@ RETURNS text LANGUAGE sql IMMUTABLE AS $$
|
|
|
1613
1613
|
THEN 'unreachable' ELSE 'untested' END;
|
|
1614
1614
|
$$;
|
|
1615
1615
|
|
|
1616
|
+
/*
|
|
1617
|
+
* The table whose rule looks itself up, or null. The twin of recursionIn in
|
|
1618
|
+
* attack.js: Postgres stops such a request rather than loop, and names the
|
|
1619
|
+
* table at fault - often not the one that was being read.
|
|
1620
|
+
*/
|
|
1621
|
+
CREATE OR REPLACE FUNCTION __KN__.recursion_in(message text)
|
|
1622
|
+
RETURNS text LANGUAGE sql IMMUTABLE AS $$
|
|
1623
|
+
SELECT (regexp_match(coalesce(message, ''),
|
|
1624
|
+
'infinite recursion detected in policy for relation "([^"]+)"', 'i'))[1];
|
|
1625
|
+
$$;
|
|
1626
|
+
|
|
1616
1627
|
/*
|
|
1617
1628
|
* Reads a table the way a request would, as whoever is asking.
|
|
1618
1629
|
*
|
|
@@ -1697,6 +1708,8 @@ DECLARE
|
|
|
1697
1708
|
findings jsonb := '[]'::jsonb;
|
|
1698
1709
|
completed jsonb := '[]'::jsonb;
|
|
1699
1710
|
blocked jsonb := '[]'::jsonb;
|
|
1711
|
+
-- Rules that looked themselves up, and which read ran into each one.
|
|
1712
|
+
looped jsonb := '[]'::jsonb;
|
|
1700
1713
|
tab jsonb;
|
|
1701
1714
|
owner text;
|
|
1702
1715
|
anon jsonb;
|
|
@@ -1713,6 +1726,22 @@ BEGIN
|
|
|
1713
1726
|
anon := __KN__.read_as(source, tab->>'name', 'anon', NULL, NULL);
|
|
1714
1727
|
as_a := __KN__.read_as(source, tab->>'name', 'authenticated', __KN__.user_a(), owner);
|
|
1715
1728
|
|
|
1729
|
+
-- Looked for on every table, owner or not. Only a read of the table itself
|
|
1730
|
+
-- that came back with a verdict, for both callers, shows its rules no
|
|
1731
|
+
-- longer loop; a read that failed for some other reason shows nothing.
|
|
1732
|
+
IF NOT (anon->>'ok')::boolean AND __KN__.recursion_in(anon->>'why') IS NOT NULL THEN
|
|
1733
|
+
looped := looped || jsonb_build_array(jsonb_build_object(
|
|
1734
|
+
'relation', __KN__.recursion_in(anon->>'why'), 'table', tab->>'name', 'who', 'anyone'));
|
|
1735
|
+
END IF;
|
|
1736
|
+
IF NOT (as_a->>'ok')::boolean AND __KN__.recursion_in(as_a->>'why') IS NOT NULL THEN
|
|
1737
|
+
looped := looped || jsonb_build_array(jsonb_build_object(
|
|
1738
|
+
'relation', __KN__.recursion_in(as_a->>'why'), 'table', tab->>'name', 'who', 'signed-in'));
|
|
1739
|
+
END IF;
|
|
1740
|
+
IF ((anon->>'ok')::boolean OR __KN__.refusal_means(anon->>'why') = 'unreachable')
|
|
1741
|
+
AND ((as_a->>'ok')::boolean OR __KN__.refusal_means(as_a->>'why') = 'unreachable') THEN
|
|
1742
|
+
completed := completed || to_jsonb('recursive:' || (tab->>'name'));
|
|
1743
|
+
END IF;
|
|
1744
|
+
|
|
1716
1745
|
-- Did this read produce a verdict, and if not, why not?
|
|
1717
1746
|
key := 'exposed:' || (tab->>'name');
|
|
1718
1747
|
IF (anon->>'ok')::boolean THEN
|
|
@@ -1765,7 +1794,8 @@ BEGIN
|
|
|
1765
1794
|
END IF;
|
|
1766
1795
|
END LOOP;
|
|
1767
1796
|
|
|
1768
|
-
RETURN jsonb_build_object('findings', findings, 'completed', completed, 'blocked', blocked
|
|
1797
|
+
RETURN jsonb_build_object('findings', findings, 'completed', completed, 'blocked', blocked,
|
|
1798
|
+
'looped', looped);
|
|
1769
1799
|
END $$;
|
|
1770
1800
|
|
|
1771
1801
|
-- --------------------------------------------------------------------------
|
|
@@ -2549,6 +2579,19 @@ BEGIN
|
|
|
2549
2579
|
|
|
2550
2580
|
findings := (seen->'findings') || (wrote->'findings') || (stranded->'findings');
|
|
2551
2581
|
|
|
2582
|
+
-- A rule that looks itself up breaks the app for whoever it fails for.
|
|
2583
|
+
-- One finding per table at fault, however many reads ran into it; the
|
|
2584
|
+
-- attacks it stopped stay in not_checked, because they did not run.
|
|
2585
|
+
findings := findings || coalesce((
|
|
2586
|
+
SELECT jsonb_agg(jsonb_build_object(
|
|
2587
|
+
'kind', 'recursive', 'table', g.relation, 'reads', g.reads,
|
|
2588
|
+
'callers', g.callers, 'columns', '[]'::jsonb))
|
|
2589
|
+
FROM (SELECT l->>'relation' AS relation,
|
|
2590
|
+
jsonb_agg(DISTINCT l->>'table') AS reads,
|
|
2591
|
+
jsonb_agg(DISTINCT l->>'who') AS callers
|
|
2592
|
+
FROM jsonb_array_elements(coalesce(seen->'looped', '[]'::jsonb)) l
|
|
2593
|
+
GROUP BY l->>'relation') g), '[]'::jsonb);
|
|
2594
|
+
|
|
2552
2595
|
-- Everything genuinely attacked, so a re-check can tell a finding that was
|
|
2553
2596
|
-- fixed from one whose attack simply did not run this time.
|
|
2554
2597
|
attempted := coalesce(seen->'completed', '[]'::jsonb)
|
package/finding.js
CHANGED
|
@@ -20,7 +20,10 @@
|
|
|
20
20
|
const IDENTITY = [
|
|
21
21
|
[/\b(email|e_mail|mail)\b/i, 'email addresses'],
|
|
22
22
|
[/\b(phone|mobile|contact_number|telephone)\b/i, 'phone numbers'],
|
|
23
|
-
|
|
23
|
+
// `\b` treats "_" as part of a word, so display_name never matched `name` -
|
|
24
|
+
// found on a blind test, where a public profiles table was reported as
|
|
25
|
+
// holding nothing in particular. The compound names are listed instead.
|
|
26
|
+
[/\b(full_name|first_name|last_name|given_name|surname|name|display_name|username|user_name|nickname|screen_name)\b/i, 'names'],
|
|
24
27
|
[/\b(address|street|city|postcode|zip|pincode)\b/i, 'addresses'],
|
|
25
28
|
[/\b(dob|date_of_birth|birth_date|birthday)\b/i, 'dates of birth'],
|
|
26
29
|
];
|
|
@@ -28,18 +31,37 @@ const IDENTITY = [
|
|
|
28
31
|
// Things that are worse than identifying - they let somebody act as the person,
|
|
29
32
|
// or take money.
|
|
30
33
|
const SECRETS = [
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
// `\b` counts "_" as a letter, so these missed every compound name - found
|
|
35
|
+
// on a blind test: integration_token, access_token and signing_secret, all
|
|
36
|
+
// readable by anyone, and the report named none of them. A name that ENDS
|
|
37
|
+
// in the secret word is the secret; token_expires_at is only about one.
|
|
38
|
+
// Singular only: `tokens` and `input_tokens` count what an AI call used -
|
|
39
|
+
// found on the next blind test, where api_usage.tokens (an integer) was
|
|
40
|
+
// named as a secret.
|
|
41
|
+
[/\b(password|passwd|pass_hash|password_hash)\b|(^|_)(password|passwd|pwd|password_hash)$/i, 'passwords'],
|
|
42
|
+
[/\b(token|api_key|apikey|secret|private_key|access_key)\b|(^|_)(token|secret|(api|private|access|secret|signing|service|encryption)_?key)$/i, 'access tokens and secrets'],
|
|
33
43
|
[/\b(card|card_number|cvv|iban|account_number|upi)\b/i, 'payment details'],
|
|
44
|
+
// A code that grants something: whoever reads a moderator_code can become a
|
|
45
|
+
// moderator. Found on a blind test, added straight in production and readable
|
|
46
|
+
// by anyone - reported only as "2 rows".
|
|
47
|
+
[/^(?:\w+_)?(moderator|invite|invitation|access|reset|verification|recovery|admin|join|auth|login|magic|security|otp)_code$|^(otp|pin)$/i, 'secret codes'],
|
|
34
48
|
];
|
|
35
49
|
|
|
50
|
+
// Who holds power in the app. Not a secret in itself, but a list of exactly
|
|
51
|
+
// which accounts are worth attacking.
|
|
52
|
+
const PRIVILEGE = [/^(is_admin|is_superuser|is_super_admin|is_staff|is_moderator|is_owner|role|roles|permissions)$/i, 'who is an admin'];
|
|
53
|
+
|
|
36
54
|
const MONEY = [/\b(amount|total|price|balance|salary|revenue|invoice)\b/i, 'amounts of money'];
|
|
37
55
|
|
|
56
|
+
// Each kind named with the columns that showed it: "secret codes
|
|
57
|
+
// (moderator_code)" can be found in the table; "secret codes" has to be
|
|
58
|
+
// looked for.
|
|
38
59
|
function matched(columns, table) {
|
|
39
|
-
const names = columns || [];
|
|
60
|
+
const names = (columns || []).map(String);
|
|
40
61
|
const found = [];
|
|
41
62
|
for (const [pattern, label] of table) {
|
|
42
|
-
|
|
63
|
+
const hits = names.filter((column) => pattern.test(column));
|
|
64
|
+
if (hits.length) found.push(label + ' (' + hits.join(', ') + ')');
|
|
43
65
|
}
|
|
44
66
|
return found;
|
|
45
67
|
}
|
|
@@ -49,7 +71,13 @@ function readContents(columns) {
|
|
|
49
71
|
const identity = matched(columns, IDENTITY);
|
|
50
72
|
const secrets = matched(columns, SECRETS);
|
|
51
73
|
const money = matched(columns, [MONEY]);
|
|
52
|
-
|
|
74
|
+
const privilege = matched(columns, [PRIVILEGE]);
|
|
75
|
+
return { identity: identity, secrets: secrets, money: money, privilege: privilege };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Everything worth naming, worst first. */
|
|
79
|
+
function heldIn(contents) {
|
|
80
|
+
return contents.secrets.concat(contents.identity, contents.privilege || [], contents.money);
|
|
53
81
|
}
|
|
54
82
|
|
|
55
83
|
/** A list, written the way a person writes one. */
|
|
@@ -72,6 +100,11 @@ function listOf(items) {
|
|
|
72
100
|
* should be fixed - there is no such thing as a low finding here.
|
|
73
101
|
*/
|
|
74
102
|
function severityOf(finding, contents) {
|
|
103
|
+
if (finding.kind === 'recursive') {
|
|
104
|
+
// Nothing leaks and nothing is written: the request fails. Serious,
|
|
105
|
+
// because the app is broken for whoever it fails for, but not a break-in.
|
|
106
|
+
return 'HIGH';
|
|
107
|
+
}
|
|
75
108
|
if (finding.kind === 'privileged') {
|
|
76
109
|
// Not proven, so never CRITICAL: this is a reach that exists, not a break
|
|
77
110
|
// that was demonstrated. A function anon can call that also writes is worth
|
|
@@ -125,6 +158,18 @@ function severityOf(finding, contents) {
|
|
|
125
158
|
* one it is saves them looking in the wrong place.
|
|
126
159
|
*/
|
|
127
160
|
function causeOf(finding) {
|
|
161
|
+
if (finding.kind === 'recursive') {
|
|
162
|
+
return {
|
|
163
|
+
short: 'a rule on it looks the same table up again',
|
|
164
|
+
long:
|
|
165
|
+
'A row level security rule on this table checks something by reading ' +
|
|
166
|
+
'this same table - usually "is this person a member?" asked of the ' +
|
|
167
|
+
'members table itself. Reading the table means applying the rule, and ' +
|
|
168
|
+
'applying the rule means reading the table, so Postgres stops the ' +
|
|
169
|
+
'request with an error instead of going round forever. Any other ' +
|
|
170
|
+
'table whose rule asks this one fails the same way.',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
128
173
|
if (finding.kind === 'orphaned') {
|
|
129
174
|
return {
|
|
130
175
|
short: 'nothing in the database ties the two tables together',
|
|
@@ -246,7 +291,19 @@ function causeOf(finding) {
|
|
|
246
291
|
/** What a caller could do, written the way a person would say it. */
|
|
247
292
|
const WRITE_WORDS = { add: 'add rows to', change: 'change rows in', delete: 'delete rows from' };
|
|
248
293
|
|
|
294
|
+
/** Who a looping rule fails for, said the way the headline and body need it. */
|
|
295
|
+
function loopVictims(finding) {
|
|
296
|
+
const callers = finding.callers || [];
|
|
297
|
+
if (callers.includes('anyone') && callers.includes('signed-in')) return 'everyone, signed in or not,';
|
|
298
|
+
if (callers.includes('anyone')) return 'visitors who are not logged in';
|
|
299
|
+
return 'signed-in users';
|
|
300
|
+
}
|
|
301
|
+
|
|
249
302
|
function headlineFor(finding) {
|
|
303
|
+
if (finding.kind === 'recursive') {
|
|
304
|
+
return 'A rule on your ' + finding.table + ' table refers to itself, so ' + loopVictims(finding) +
|
|
305
|
+
' get an error instead of data.';
|
|
306
|
+
}
|
|
250
307
|
if (finding.kind === 'orphaned') {
|
|
251
308
|
return 'Your ' + finding.table + ' table can point at a ' +
|
|
252
309
|
finding.parent.replace(/s$/, '') + ' that does not exist.';
|
|
@@ -292,9 +349,27 @@ function costOf(finding) {
|
|
|
292
349
|
}
|
|
293
350
|
|
|
294
351
|
function bodyFor(finding, contents) {
|
|
295
|
-
const holds = listOf(
|
|
352
|
+
const holds = listOf(heldIn(contents));
|
|
296
353
|
const rowWord = finding.readable === 1 ? 'row' : 'rows';
|
|
297
354
|
|
|
355
|
+
if (finding.kind === 'recursive') {
|
|
356
|
+
// Said as what happened to the requests, and then what it cost this
|
|
357
|
+
// report: the tables behind the error could not be tested for leaks.
|
|
358
|
+
const reads = finding.reads || [];
|
|
359
|
+
const callers = finding.callers || [];
|
|
360
|
+
const as = [];
|
|
361
|
+
if (callers.includes('anyone')) as.push('a visitor who is not logged in');
|
|
362
|
+
if (callers.includes('signed-in')) as.push('a signed-in user');
|
|
363
|
+
const one = reads.length === 1;
|
|
364
|
+
return (
|
|
365
|
+
'Using ' + listOf(reads) + ' on a copy of your app as ' + as.join(' and as ') +
|
|
366
|
+
', every request was refused with "infinite recursion detected in policy for relation ' +
|
|
367
|
+
finding.table + '". Every page in your app that uses ' + (one ? 'that table' : 'those tables') +
|
|
368
|
+
' fails the same way for ' + loopVictims(finding).replace(/,$/, '') + '. It also means I could ' +
|
|
369
|
+
'not test ' + (one ? 'it' : 'them') + ' for leaks - those attacks are listed at the end.'
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
298
373
|
if (finding.kind === 'orphaned') {
|
|
299
374
|
// Said as the thing a person will actually meet: a customer asks to be
|
|
300
375
|
// deleted, and their rows are still here afterwards.
|
|
@@ -308,7 +383,7 @@ function bodyFor(finding, contents) {
|
|
|
308
383
|
}
|
|
309
384
|
|
|
310
385
|
if (finding.kind === 'writable') {
|
|
311
|
-
const holds = listOf(
|
|
386
|
+
const holds = listOf(heldIn(contents));
|
|
312
387
|
const who = finding.who === 'anyone'
|
|
313
388
|
? 'Without logging in and without an account, I '
|
|
314
389
|
: 'Signed in as one of your customers, I ';
|
|
@@ -437,9 +512,33 @@ function authTiesOf(plan) {
|
|
|
437
512
|
* locks the app out of its own table. Or the table has no column saying whose
|
|
438
513
|
* row it is - a waitlist, a contact form - so there is nothing to compare.
|
|
439
514
|
*/
|
|
515
|
+
// A column that says which organization a row belongs to. Such a table is
|
|
516
|
+
// not "nobody's" - its rows are a team's.
|
|
517
|
+
const TENANT_COLUMN = /^(org_id|organization_id|workspace_id|team_id|tenant_id|company_id)$/i;
|
|
518
|
+
|
|
440
519
|
function serverOnlyLines(finding) {
|
|
441
520
|
const table = finding.table;
|
|
442
521
|
let why;
|
|
522
|
+
// Found on a fix test: api_usage has org_id, and the prompt said it had no
|
|
523
|
+
// column saying whose row it is. The assistant applying it had to correct
|
|
524
|
+
// the prompt; a less careful one would have locked the table away from the
|
|
525
|
+
// members who use it.
|
|
526
|
+
const tenant = finding.authTied !== false && finding.owned === false &&
|
|
527
|
+
(finding.columns || []).find((c) => TENANT_COLUMN.test(String(c)));
|
|
528
|
+
if (tenant) {
|
|
529
|
+
return [
|
|
530
|
+
'Rows in "' + table + '" belong to an organization ("' + tenant + '"), not to one person. So the ' +
|
|
531
|
+
'rule should let a signed-in user in only when they are a member of that row\'s organization - ' +
|
|
532
|
+
'checked through your membership table, or a helper function that asks it - and give logged-out ' +
|
|
533
|
+
'visitors nothing.',
|
|
534
|
+
'',
|
|
535
|
+
'Write separate rules for reading, inserting, updating and deleting, each with that membership ' +
|
|
536
|
+
'check (USING for reading, updating and deleting, WITH CHECK for inserting and updating), and ' +
|
|
537
|
+
'decide whether every member should be able to write or only admins. If only your server ' +
|
|
538
|
+
'writes this table, add no write rules at all.',
|
|
539
|
+
'',
|
|
540
|
+
];
|
|
541
|
+
}
|
|
443
542
|
if (finding.authTied === false) {
|
|
444
543
|
why = 'Nothing in "' + table + '" points at auth.users and no rule on it uses auth.uid(), so your ' +
|
|
445
544
|
'app signs people in its own way. A rule comparing a column to the signed-in user cannot ' +
|
|
@@ -520,7 +619,32 @@ function fixPromptFor(finding) {
|
|
|
520
619
|
const cause = causeOf(finding);
|
|
521
620
|
const owner = finding.owner ? '"' + finding.owner + '"' : 'the column that says who each row belongs to';
|
|
522
621
|
|
|
523
|
-
const lines = finding.kind === '
|
|
622
|
+
const lines = finding.kind === 'recursive'
|
|
623
|
+
? [
|
|
624
|
+
'My app has a broken database rule.',
|
|
625
|
+
'',
|
|
626
|
+
'A row level security policy on the "' + finding.table + '" table reads "' + finding.table +
|
|
627
|
+
'" itself, so Postgres stops every request that touches it with "infinite recursion ' +
|
|
628
|
+
'detected in policy for relation ' + finding.table + '". I saw it when using ' +
|
|
629
|
+
listOf((finding.reads || []).map((t) => '"' + t + '"')) + '.',
|
|
630
|
+
'',
|
|
631
|
+
'Show me the policies on "' + finding.table + '" and on every table whose policy reads "' +
|
|
632
|
+
finding.table + '", and find the lookup that goes back into "' + finding.table + '".',
|
|
633
|
+
'',
|
|
634
|
+
'Move that lookup into a SECURITY DEFINER function that answers only about the signed-in ' +
|
|
635
|
+
'user - for example is_member_of(org uuid) returning whether auth.uid() is in that ' +
|
|
636
|
+
'organization - with SET search_path fixed, owned by the table\'s owner so it reads the ' +
|
|
637
|
+
'table without the rule applying again. Grant EXECUTE on it to authenticated only. Then ' +
|
|
638
|
+
'make the policies call that function instead of reading "' + finding.table + '" directly.',
|
|
639
|
+
'',
|
|
640
|
+
'Keep each policy exactly as strict as it was meant to be: members see only their own ' +
|
|
641
|
+
'organization\'s rows, and logged-out visitors get nothing. Do not replace it with USING ' +
|
|
642
|
+
'(true) to make the error go away.',
|
|
643
|
+
'',
|
|
644
|
+
'Then sign in as an ordinary user and open the pages that use these tables, and run this ' +
|
|
645
|
+
'check again - the tables it could not test will be tested this time.',
|
|
646
|
+
]
|
|
647
|
+
: finding.kind === 'privileged'
|
|
524
648
|
? [
|
|
525
649
|
'My app may have a security problem - please check it rather than assume it.',
|
|
526
650
|
'',
|
|
@@ -721,7 +845,10 @@ function describe(finding) {
|
|
|
721
845
|
cause: finding.kind === 'privileged' ? '' : cause.long,
|
|
722
846
|
who: finding.who,
|
|
723
847
|
can: finding.can,
|
|
724
|
-
proof: finding.kind === '
|
|
848
|
+
proof: finding.kind === 'recursive'
|
|
849
|
+
? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
|
|
850
|
+
'"infinite recursion detected in policy for relation ' + finding.table + '".'
|
|
851
|
+
: finding.kind === 'privileged'
|
|
725
852
|
? 'I did not call "' + finding.fn + '". This is a reach that exists in the grants, not a break I ran.'
|
|
726
853
|
: finding.kind === 'duplicated'
|
|
727
854
|
? 'I created ' + finding.copies + ' rows in "' + finding.table + '" holding the same ' +
|
|
@@ -825,7 +952,9 @@ function describeAll(findings) {
|
|
|
825
952
|
// ones that need an account, and those before the ones that need two requests
|
|
826
953
|
// to arrive together.
|
|
827
954
|
// Writes above reads: a table somebody emptied is worse than one they read.
|
|
828
|
-
|
|
955
|
+
// A looping rule sits with the reads: it breaks the app for everyone it
|
|
956
|
+
// fails for, which matters more than a duplicate or a stray row.
|
|
957
|
+
const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, duplicated: 3, orphaned: 4, privileged: 5 };
|
|
829
958
|
const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
|
|
830
959
|
return described.sort((a, b) => rank(a) - rank(b));
|
|
831
960
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kryptheon-night",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
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,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 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 cli.check.js && node loop.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",
|
|
@@ -53,6 +53,8 @@
|
|
|
53
53
|
"check:verdicts": "node verdicts.check.js",
|
|
54
54
|
"check:shop": "node shop.check.js",
|
|
55
55
|
"check:privileged": "node privileged.check.js",
|
|
56
|
+
"check:recursion": "node recursion.check.js",
|
|
57
|
+
"check:guarded": "node guarded.check.js",
|
|
56
58
|
"check:cli": "node cli.check.js",
|
|
57
59
|
"check:writes": "node tamper.check.js",
|
|
58
60
|
"check:orphans": "node orphans.check.js",
|
package/recheck.js
CHANGED
|
@@ -172,6 +172,7 @@ function whatOf(item) {
|
|
|
172
172
|
if (item.kind === 'duplicated') return 'the same ' + (item.column || 'value') + ' twice';
|
|
173
173
|
if (item.kind === 'orphaned') return 'rows left pointing at nothing';
|
|
174
174
|
if (item.kind === 'privileged') return 'a function anyone can call';
|
|
175
|
+
if (item.kind === 'recursive') return 'a rule that refers to itself';
|
|
175
176
|
return item.kind;
|
|
176
177
|
}
|
|
177
178
|
|
|
@@ -191,7 +192,14 @@ function describe(result) {
|
|
|
191
192
|
lines.push(' ' + result.fixed.length + (result.fixed.length === 1 ? ' problem is' : ' problems are') + ' fixed:');
|
|
192
193
|
lines.push('');
|
|
193
194
|
for (const item of result.fixed) {
|
|
194
|
-
|
|
195
|
+
// A function is never called, only read - so its fix is said as what
|
|
196
|
+
// was seen, not as an attack that was refused. Found on a fix test.
|
|
197
|
+
const how = item.kind === 'privileged'
|
|
198
|
+
? 'I looked again, and a visitor with no account can no longer call it.'
|
|
199
|
+
: item.kind === 'recursive'
|
|
200
|
+
? 'I read it again, and its rules answered instead of stopping with an error.'
|
|
201
|
+
: 'I ran the same attack again and it was refused.';
|
|
202
|
+
lines.push(' ' + item.table + ' (' + whatOf(item) + ') - ' + how);
|
|
195
203
|
}
|
|
196
204
|
lines.push('');
|
|
197
205
|
}
|
package/scan.js
CHANGED
|
@@ -269,6 +269,10 @@ async function scan(client, sourceSchema, options) {
|
|
|
269
269
|
const viewNames = new Set(views.map((view) => view.name));
|
|
270
270
|
const attempted = impersonation.completed.filter((key) => {
|
|
271
271
|
const name = key.split(':')[1];
|
|
272
|
+
// Whether a rule loops needs no rows: Postgres finds the loop while
|
|
273
|
+
// planning the read, before it looks at a single row. A table nothing
|
|
274
|
+
// could be seeded into has still been checked for that.
|
|
275
|
+
if (key.startsWith('recursive:')) return true;
|
|
272
276
|
return sownTables.has(name) || (viewNames.has(name) && sown.seeded.length > 0);
|
|
273
277
|
});
|
|
274
278
|
|
|
@@ -310,6 +314,25 @@ async function scan(client, sourceSchema, options) {
|
|
|
310
314
|
});
|
|
311
315
|
}
|
|
312
316
|
|
|
317
|
+
// A rule that looks itself up. The attacks it stopped stay in the list of
|
|
318
|
+
// what was not tested - they did not run - but the loop is also a finding
|
|
319
|
+
// of its own: every signed-in request that touches the table fails, so the
|
|
320
|
+
// app is broken for the people using it. One finding per table at fault,
|
|
321
|
+
// named from Postgres's own message, however many reads ran into it.
|
|
322
|
+
const loops = new Map();
|
|
323
|
+
const loopedInto = (relation, table, who) => {
|
|
324
|
+
if (!loops.has(relation)) loops.set(relation, { kind: 'recursive', table: relation, reads: [], callers: [], columns: [] });
|
|
325
|
+
const entry = loops.get(relation);
|
|
326
|
+
if (!entry.reads.includes(table)) entry.reads.push(table);
|
|
327
|
+
if (!entry.callers.includes(who)) entry.callers.push(who);
|
|
328
|
+
};
|
|
329
|
+
for (const hit of impersonation.looped || []) loopedInto(hit.relation, hit.table, hit.who);
|
|
330
|
+
for (const stuck of writes.blocked) {
|
|
331
|
+
const relation = attack.recursionIn(stuck.why);
|
|
332
|
+
if (relation) loopedInto(relation, stuck.table, /^as anyone:/.test(stuck.why) ? 'anyone' : 'signed-in');
|
|
333
|
+
}
|
|
334
|
+
const recursive = Array.from(loops.values());
|
|
335
|
+
|
|
313
336
|
let collisions = { findings: [], notTried: [], raced: [] };
|
|
314
337
|
if (opts.openSession) {
|
|
315
338
|
say(' Racing two requests against each other ...');
|
|
@@ -362,7 +385,9 @@ async function scan(client, sourceSchema, options) {
|
|
|
362
385
|
// version recorded only the flagged ones, so a revoke made the function
|
|
363
386
|
// vanish from both lists and the fix came back as "could not confirm".
|
|
364
387
|
for (const fn of plan.anonFunctions || []) attempted.push('privileged:' + fn.name);
|
|
365
|
-
|
|
388
|
+
// Quiet: a yes/no about the caller, and a function that turns the caller
|
|
389
|
+
// away before it does anything (schema.guardedAtTheDoor).
|
|
390
|
+
const privileged = (plan.anonFunctions || []).filter((fn) => fn.callable && !fn.aboutCaller && !fn.guarded).map((fn) => ({
|
|
366
391
|
kind: 'privileged',
|
|
367
392
|
fn: fn.name,
|
|
368
393
|
table: fn.name,
|
|
@@ -389,7 +414,7 @@ async function scan(client, sourceSchema, options) {
|
|
|
389
414
|
notChecked: notChecked,
|
|
390
415
|
attempted: attempted,
|
|
391
416
|
findings: finding.describeAll(
|
|
392
|
-
impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged).map(tagged),
|
|
417
|
+
impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged, recursive).map(tagged),
|
|
393
418
|
),
|
|
394
419
|
};
|
|
395
420
|
} finally {
|
package/schema.js
CHANGED
|
@@ -206,6 +206,49 @@ async function readIndexes(client, schema) {
|
|
|
206
206
|
* re-check needs to know the function was looked at again and the grant was
|
|
207
207
|
* gone, which is different from the function no longer being there at all.
|
|
208
208
|
*/
|
|
209
|
+
/**
|
|
210
|
+
* Whether a function turns the caller away before it does anything.
|
|
211
|
+
*
|
|
212
|
+
* Found on a blind test: create_admin_export opens with
|
|
213
|
+
* if not public.is_org_admin(p_org_id) then raise exception ...
|
|
214
|
+
* and was reported as "anyone can call it, with full rights". A visitor with
|
|
215
|
+
* no account is not an admin of anything, so the first line refuses them and
|
|
216
|
+
* nothing else runs. That is a guard, and the function is examined, not
|
|
217
|
+
* reported.
|
|
218
|
+
*
|
|
219
|
+
* Only a refusal counts - IF NOT <check> THEN RAISE - and only one that comes
|
|
220
|
+
* before the first write and the first RETURN, because a check made after the
|
|
221
|
+
* work is done guards nothing. The check has to ask about the caller: a call
|
|
222
|
+
* to one of the schema's yes/no helpers that ask auth.uid(), or a condition
|
|
223
|
+
* of its own that asks auth.uid() whether they are an admin, owner or member.
|
|
224
|
+
* "Is anybody signed in at all" is not enough, and neither is a check on an
|
|
225
|
+
* argument the caller chose.
|
|
226
|
+
*/
|
|
227
|
+
function guardedAtTheDoor(body, helpers) {
|
|
228
|
+
const text = String(body || '');
|
|
229
|
+
const writes = /\b(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\b/i.exec(text);
|
|
230
|
+
const returns = /\breturn\b/i.exec(text);
|
|
231
|
+
const firstAct = Math.min(writes ? writes.index : Infinity, returns ? returns.index : Infinity);
|
|
232
|
+
// The condition may not run past a THEN, or two IFs would be read as one;
|
|
233
|
+
// and END IF is not the start of one.
|
|
234
|
+
const guard = /(?<!\bend\s+)\bif\b((?:(?!\bthen\b)[\s\S])*?)\bthen\s+raise\b(?!\s+(?:notice|warning|info|log|debug)\b)/gi;
|
|
235
|
+
// The name as a call, bare or schema-qualified (public.is_org_admin), and
|
|
236
|
+
// never as the tail of a longer name.
|
|
237
|
+
const asks = (cond, name) => new RegExp('(^|[^\\w$"])"?' +
|
|
238
|
+
name.replace(/[$]/g, '\\$') + '"?\\s*\\(', 'i').test(cond);
|
|
239
|
+
let found;
|
|
240
|
+
while ((found = guard.exec(text)) !== null) {
|
|
241
|
+
if (found.index > firstAct) break;
|
|
242
|
+
const cond = found[1].trim();
|
|
243
|
+
// Refusing when the check fails, not when it passes.
|
|
244
|
+
const refusesOnNo = /^not\b/i.test(cond) || /\bis\s+(not\s+true|false)\s*$/i.test(cond) || /=\s*false\s*$/i.test(cond);
|
|
245
|
+
if (!refusesOnNo) continue;
|
|
246
|
+
if ((helpers || []).some((name) => asks(cond, name))) return true;
|
|
247
|
+
if (/\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(cond) && /admin|owner|member|role/i.test(cond)) return true;
|
|
248
|
+
}
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
|
|
209
252
|
async function readAnonDefinerFunctions(client, schema, role) {
|
|
210
253
|
const caller = role || 'anon';
|
|
211
254
|
// A plain Postgres with no anon role has no anonymous caller at all, so
|
|
@@ -218,6 +261,7 @@ async function readAnonDefinerFunctions(client, schema, role) {
|
|
|
218
261
|
pg_get_function_identity_arguments(p.oid) AS args,
|
|
219
262
|
p.proconfig::text AS config,
|
|
220
263
|
pg_get_functiondef(p.oid) AS def,
|
|
264
|
+
p.prorettype = 'pg_catalog.bool'::regtype AS returns_bool,
|
|
221
265
|
${exists.rows.length ? "has_function_privilege($2, p.oid, 'EXECUTE')" : 'false'} AS callable
|
|
222
266
|
FROM pg_proc p
|
|
223
267
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
@@ -227,6 +271,21 @@ async function readAnonDefinerFunctions(client, schema, role) {
|
|
|
227
271
|
ORDER BY p.proname`,
|
|
228
272
|
exists.rows.length ? [schema, caller] : [schema],
|
|
229
273
|
);
|
|
274
|
+
// The schema's yes/no helpers about the caller - is_org_admin(org) asking
|
|
275
|
+
// whether auth.uid() is an admin of it - definer or not, since a guard can
|
|
276
|
+
// call either. Read once, for guardedAtTheDoor.
|
|
277
|
+
const { rows: yesNo } = await client.query(
|
|
278
|
+
`SELECT p.proname AS name, pg_get_functiondef(p.oid) AS def
|
|
279
|
+
FROM pg_proc p
|
|
280
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
281
|
+
WHERE n.nspname = $1 AND p.prokind = 'f' AND p.prorettype = 'pg_catalog.bool'::regtype`,
|
|
282
|
+
[schema],
|
|
283
|
+
);
|
|
284
|
+
const helpers = yesNo.filter((row) => {
|
|
285
|
+
const body = String(row.def || '').replace(/--[^\n]*/g, ' ');
|
|
286
|
+
return /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(body) &&
|
|
287
|
+
!/\b(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\b/i.test(body);
|
|
288
|
+
}).map((row) => row.name);
|
|
230
289
|
return rows.map((row) => {
|
|
231
290
|
// A definer function with no search_path pinned is a second, separate
|
|
232
291
|
// hazard: the caller can set their own search_path and make the function
|
|
@@ -238,11 +297,25 @@ async function readAnonDefinerFunctions(client, schema, role) {
|
|
|
238
297
|
// could fool this, and that is acceptable: it never turns a non-finding
|
|
239
298
|
// into a finding, only "can read" into "can change".
|
|
240
299
|
const body = String(row.def || '').replace(/--[^\n]*/g, ' ');
|
|
241
|
-
|
|
300
|
+
// `update\s+\w` followed by \b matched only a one-letter table name, so
|
|
301
|
+
// UPDATE was never seen - archive_notebook was worded as reading. The
|
|
302
|
+
// whole name, quoted or not, is matched now.
|
|
303
|
+
const writes = /\b(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\b/i.test(body);
|
|
304
|
+
// A yes/no about the caller alone - is_org_member(org) asking whether
|
|
305
|
+
// auth.uid() belongs to it. Found on a blind test, where two such helpers
|
|
306
|
+
// were reported as "anyone can call it, with full rights": they are how
|
|
307
|
+
// row level security avoids checking a table against itself, and to a
|
|
308
|
+
// visitor with no account they answer no. Reading nothing about anybody
|
|
309
|
+
// else and writing nothing, they are not a reach. A yes/no that does not
|
|
310
|
+
// ask auth.uid() - is_admin(user_id) - answers about other people, and
|
|
311
|
+
// stays a finding.
|
|
312
|
+
const aboutCaller = row.returns_bool === true && !writes && /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(body);
|
|
242
313
|
return {
|
|
243
314
|
name: row.name,
|
|
244
315
|
args: row.args || '',
|
|
245
316
|
writes: writes,
|
|
317
|
+
aboutCaller: aboutCaller,
|
|
318
|
+
guarded: guardedAtTheDoor(body, helpers),
|
|
246
319
|
hasFixedSearchPath: hasFixedSearchPath,
|
|
247
320
|
callable: row.callable === true,
|
|
248
321
|
};
|
|
@@ -1192,6 +1265,7 @@ function diffSchemas(source, copy) {
|
|
|
1192
1265
|
module.exports = {
|
|
1193
1266
|
readSchema: readSchema,
|
|
1194
1267
|
readAnonDefinerFunctions: readAnonDefinerFunctions,
|
|
1268
|
+
guardedAtTheDoor: guardedAtTheDoor,
|
|
1195
1269
|
writeSchema: writeSchema,
|
|
1196
1270
|
diffSchemas: diffSchemas,
|
|
1197
1271
|
readPolicies: readPolicies,
|