kryptheon-night 0.1.10 → 0.1.19
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/bin/kryptheon-night.js +34 -8
- package/engine.sql +44 -1
- package/finding.js +181 -17
- package/installer.js +16 -0
- package/package.json +5 -2
- package/recheck.js +9 -1
- package/scan.js +111 -9
- package/schema.js +75 -1
- package/store.js +2 -0
- package/trouble.js +11 -6
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/bin/kryptheon-night.js
CHANGED
|
@@ -118,6 +118,30 @@ function readArgs(argv) {
|
|
|
118
118
|
return asked;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Keeps what the nightly run last said where the kryptheon dashboard reads
|
|
123
|
+
* it, so the answer waiting in the database is also on the page - not only
|
|
124
|
+
* what was last run from this terminal. Never fails anything: the person has
|
|
125
|
+
* their answer already, and a folder that cannot be written is not theirs to
|
|
126
|
+
* debug here.
|
|
127
|
+
*/
|
|
128
|
+
function keepTheNight(latest) {
|
|
129
|
+
try {
|
|
130
|
+
scanner.saveNightly(scanner.nightlyFile(), scanner.nightlyRecord(latest, new Date().toISOString()));
|
|
131
|
+
} catch (err) {
|
|
132
|
+
/* the dashboard keeps its older copy, which carries its own date */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Reads and keeps it, for the doors that do not already hold the answer. */
|
|
137
|
+
async function readAndKeepTheNight(client) {
|
|
138
|
+
try {
|
|
139
|
+
keepTheNight(await installer.latestRun(client, {}));
|
|
140
|
+
} catch (err) {
|
|
141
|
+
/* as above */
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
121
145
|
/**
|
|
122
146
|
* The three doors that are not a scan.
|
|
123
147
|
*
|
|
@@ -127,6 +151,7 @@ function readArgs(argv) {
|
|
|
127
151
|
*/
|
|
128
152
|
async function runVerb(command, client, target) {
|
|
129
153
|
if (command === 'status') {
|
|
154
|
+
await readAndKeepTheNight(client);
|
|
130
155
|
const where = await installer.status(client, {});
|
|
131
156
|
if (!where) {
|
|
132
157
|
line(' The nightly run is not installed in this database.');
|
|
@@ -183,6 +208,7 @@ async function runVerb(command, client, target) {
|
|
|
183
208
|
return 0;
|
|
184
209
|
}
|
|
185
210
|
const removed = await installer.uninstall(client, {});
|
|
211
|
+
await readAndKeepTheNight(client);
|
|
186
212
|
line(' Removed.');
|
|
187
213
|
line('');
|
|
188
214
|
line(' the nightly job ' + (removed.job ? 'gone' : 'there was none'));
|
|
@@ -195,7 +221,9 @@ async function runVerb(command, client, target) {
|
|
|
195
221
|
}
|
|
196
222
|
|
|
197
223
|
if (command === 'night') {
|
|
198
|
-
const
|
|
224
|
+
const latest = await installer.latestRun(client, {});
|
|
225
|
+
keepTheNight(latest);
|
|
226
|
+
const where = latest.where;
|
|
199
227
|
if (!where) {
|
|
200
228
|
line(' The nightly run is not installed in this database, so there is');
|
|
201
229
|
line(' nothing to read back.');
|
|
@@ -205,12 +233,7 @@ async function runVerb(command, client, target) {
|
|
|
205
233
|
line('');
|
|
206
234
|
return 2;
|
|
207
235
|
}
|
|
208
|
-
|
|
209
|
-
const { rows } = await client.query(
|
|
210
|
-
'SELECT ran_at, source, stopped, attacks_run, findings, not_checked ' +
|
|
211
|
-
'FROM ' + quoted + '.runs ORDER BY ran_at DESC LIMIT 1',
|
|
212
|
-
);
|
|
213
|
-
if (!rows.length) {
|
|
236
|
+
if (!latest.run) {
|
|
214
237
|
// Installed and never run is not the same as run and found nothing, and
|
|
215
238
|
// saying "nothing got through" here would be the worst sentence in the
|
|
216
239
|
// product printed about a night that never happened.
|
|
@@ -220,7 +243,7 @@ async function runVerb(command, client, target) {
|
|
|
220
243
|
line('');
|
|
221
244
|
return 0;
|
|
222
245
|
}
|
|
223
|
-
const run =
|
|
246
|
+
const run = latest.run;
|
|
224
247
|
line(' From the night of ' + new Date(run.ran_at).toISOString().slice(0, 16).replace('T', ' ') +
|
|
225
248
|
', on "' + run.source + '":');
|
|
226
249
|
scanner.report({
|
|
@@ -583,6 +606,9 @@ async function main() {
|
|
|
583
606
|
// think about it again, which is exactly the app that is unprotected
|
|
584
607
|
// three months later. The tool already knows whether the nightly run is
|
|
585
608
|
// there. So it asks.
|
|
609
|
+
// Whatever the nightly run last said is kept for the dashboard too, so
|
|
610
|
+
// the page shows both answers and says which is which.
|
|
611
|
+
await readAndKeepTheNight(client);
|
|
586
612
|
if (!result.stopped) await offerTheNight(client, target, canAsk);
|
|
587
613
|
} catch (err) {
|
|
588
614
|
// Anything that went wrong mid-scan. The copy has already been dropped by
|
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,57 @@ 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 team = finding.authTied !== false && (finding.columns || []).find((c) => TENANT_COLUMN.test(String(c)));
|
|
527
|
+
const maker = finding.owner || finding.ownedBy;
|
|
528
|
+
// A team's row that also says who made it - created_by beside workspace_id.
|
|
529
|
+
// Found on a benchmark: the prompt said to compare created_by to auth.uid()
|
|
530
|
+
// for reading, which hides every teammate's rows and breaks the shared
|
|
531
|
+
// view the table exists for. Who made a row decides who may write it
|
|
532
|
+
// under their name, not who may see it.
|
|
533
|
+
if (team && finding.owned !== false && maker) {
|
|
534
|
+
return [
|
|
535
|
+
'Rows in "' + table + '" belong to a team ("' + team + '"), and "' + maker + '" only records who ' +
|
|
536
|
+
'made each one. So reading should be decided by team membership: let a signed-in user read a ' +
|
|
537
|
+
'row when they are a member of that row\'s "' + team + '" - checked through your membership ' +
|
|
538
|
+
'table, or a helper function that asks it - and give logged-out visitors nothing. Do not ' +
|
|
539
|
+
'compare "' + maker + '" to auth.uid() for reading: that hides teammates\' rows from each other ' +
|
|
540
|
+
'and breaks the feature.',
|
|
541
|
+
'',
|
|
542
|
+
'Write separate rules for reading, inserting, updating and deleting. Inserting: WITH CHECK that ' +
|
|
543
|
+
'the user is a member of the row\'s "' + team + '" AND "' + maker + '" = auth.uid(), so nobody ' +
|
|
544
|
+
'writes a row under somebody else\'s name or into a team they are not in. Updating and ' +
|
|
545
|
+
'deleting: the membership check in USING (and WITH CHECK for updating), and decide whether any ' +
|
|
546
|
+
'member may do it or only the row\'s "' + maker + '" or an admin. If only your server writes ' +
|
|
547
|
+
'this table, add no write rules at all.',
|
|
548
|
+
'',
|
|
549
|
+
];
|
|
550
|
+
}
|
|
551
|
+
const tenant = finding.owned === false && team;
|
|
552
|
+
if (tenant) {
|
|
553
|
+
return [
|
|
554
|
+
'Rows in "' + table + '" belong to an organization ("' + tenant + '"), not to one person. So the ' +
|
|
555
|
+
'rule should let a signed-in user in only when they are a member of that row\'s organization - ' +
|
|
556
|
+
'checked through your membership table, or a helper function that asks it - and give logged-out ' +
|
|
557
|
+
'visitors nothing.',
|
|
558
|
+
'',
|
|
559
|
+
'Write separate rules for reading, inserting, updating and deleting, each with that membership ' +
|
|
560
|
+
'check (USING for reading, updating and deleting, WITH CHECK for inserting and updating), and ' +
|
|
561
|
+
'decide whether every member should be able to write or only admins. If only your server ' +
|
|
562
|
+
'writes this table, add no write rules at all.',
|
|
563
|
+
'',
|
|
564
|
+
];
|
|
565
|
+
}
|
|
443
566
|
if (finding.authTied === false) {
|
|
444
567
|
why = 'Nothing in "' + table + '" points at auth.users and no rule on it uses auth.uid(), so your ' +
|
|
445
568
|
'app signs people in its own way. A rule comparing a column to the signed-in user cannot ' +
|
|
@@ -464,6 +587,18 @@ function serverOnlyLines(finding) {
|
|
|
464
587
|
];
|
|
465
588
|
}
|
|
466
589
|
|
|
590
|
+
/**
|
|
591
|
+
* The opening of a rules fix. "Switch row level security on" is said only
|
|
592
|
+
* when it is off: told it about a table where it was already on, the person
|
|
593
|
+
* looks for a switch that is already flipped and misses the rule that is the
|
|
594
|
+
* real problem.
|
|
595
|
+
*/
|
|
596
|
+
function switchOn(finding) {
|
|
597
|
+
return finding.rlsEnabled
|
|
598
|
+
? 'Row level security is already on for this table, so the rules on it are the problem: '
|
|
599
|
+
: 'Switch row level security on for this table, then ';
|
|
600
|
+
}
|
|
601
|
+
|
|
467
602
|
/**
|
|
468
603
|
* One prompt for everything wrong with one table's rules - read and write,
|
|
469
604
|
* strangers and customers - because it is one rule to replace, not four.
|
|
@@ -498,9 +633,8 @@ function groupPromptFor(members) {
|
|
|
498
633
|
'',
|
|
499
634
|
].concat(
|
|
500
635
|
serverOnlyLines(lead) || [
|
|
501
|
-
|
|
502
|
-
'
|
|
503
|
-
'covers far more than reading.',
|
|
636
|
+
switchOn(lead) + 'remove the rule that lets everyone in, and write separate rules for reading, ' +
|
|
637
|
+
'inserting, updating and deleting. A rule written FOR ALL covers far more than reading.',
|
|
504
638
|
'',
|
|
505
639
|
'Each of them should compare ' + ownerWords + ' to the id of the signed-in user (auth.uid()): ' +
|
|
506
640
|
'USING for reading, updating and deleting, WITH CHECK for inserting and updating, so nobody ' +
|
|
@@ -520,7 +654,32 @@ function fixPromptFor(finding) {
|
|
|
520
654
|
const cause = causeOf(finding);
|
|
521
655
|
const owner = finding.owner ? '"' + finding.owner + '"' : 'the column that says who each row belongs to';
|
|
522
656
|
|
|
523
|
-
const lines = finding.kind === '
|
|
657
|
+
const lines = finding.kind === 'recursive'
|
|
658
|
+
? [
|
|
659
|
+
'My app has a broken database rule.',
|
|
660
|
+
'',
|
|
661
|
+
'A row level security policy on the "' + finding.table + '" table reads "' + finding.table +
|
|
662
|
+
'" itself, so Postgres stops every request that touches it with "infinite recursion ' +
|
|
663
|
+
'detected in policy for relation ' + finding.table + '". I saw it when using ' +
|
|
664
|
+
listOf((finding.reads || []).map((t) => '"' + t + '"')) + '.',
|
|
665
|
+
'',
|
|
666
|
+
'Show me the policies on "' + finding.table + '" and on every table whose policy reads "' +
|
|
667
|
+
finding.table + '", and find the lookup that goes back into "' + finding.table + '".',
|
|
668
|
+
'',
|
|
669
|
+
'Move that lookup into a SECURITY DEFINER function that answers only about the signed-in ' +
|
|
670
|
+
'user - for example is_member_of(org uuid) returning whether auth.uid() is in that ' +
|
|
671
|
+
'organization - with SET search_path fixed, owned by the table\'s owner so it reads the ' +
|
|
672
|
+
'table without the rule applying again. Grant EXECUTE on it to authenticated only. Then ' +
|
|
673
|
+
'make the policies call that function instead of reading "' + finding.table + '" directly.',
|
|
674
|
+
'',
|
|
675
|
+
'Keep each policy exactly as strict as it was meant to be: members see only their own ' +
|
|
676
|
+
'organization\'s rows, and logged-out visitors get nothing. Do not replace it with USING ' +
|
|
677
|
+
'(true) to make the error go away.',
|
|
678
|
+
'',
|
|
679
|
+
'Then sign in as an ordinary user and open the pages that use these tables, and run this ' +
|
|
680
|
+
'check again - the tables it could not test will be tested this time.',
|
|
681
|
+
]
|
|
682
|
+
: finding.kind === 'privileged'
|
|
524
683
|
? [
|
|
525
684
|
'My app may have a security problem - please check it rather than assume it.',
|
|
526
685
|
'',
|
|
@@ -582,9 +741,9 @@ function fixPromptFor(finding) {
|
|
|
582
741
|
'',
|
|
583
742
|
].concat(
|
|
584
743
|
serverOnlyLines(finding) || [
|
|
585
|
-
|
|
586
|
-
'
|
|
587
|
-
'
|
|
744
|
+
switchOn(finding) + 'write separate rules for reading, inserting, updating and deleting. ' +
|
|
745
|
+
'A rule written FOR SELECT does not cover writes, and a rule written FOR ALL covers ' +
|
|
746
|
+
'far more than reading.',
|
|
588
747
|
'',
|
|
589
748
|
'For insert and update, use WITH CHECK comparing ' + owner +
|
|
590
749
|
' to the id of the signed-in user, so nobody can write a row under somebody ' +
|
|
@@ -721,7 +880,10 @@ function describe(finding) {
|
|
|
721
880
|
cause: finding.kind === 'privileged' ? '' : cause.long,
|
|
722
881
|
who: finding.who,
|
|
723
882
|
can: finding.can,
|
|
724
|
-
proof: finding.kind === '
|
|
883
|
+
proof: finding.kind === 'recursive'
|
|
884
|
+
? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
|
|
885
|
+
'"infinite recursion detected in policy for relation ' + finding.table + '".'
|
|
886
|
+
: finding.kind === 'privileged'
|
|
725
887
|
? 'I did not call "' + finding.fn + '". This is a reach that exists in the grants, not a break I ran.'
|
|
726
888
|
: finding.kind === 'duplicated'
|
|
727
889
|
? 'I created ' + finding.copies + ' rows in "' + finding.table + '" holding the same ' +
|
|
@@ -825,7 +987,9 @@ function describeAll(findings) {
|
|
|
825
987
|
// ones that need an account, and those before the ones that need two requests
|
|
826
988
|
// to arrive together.
|
|
827
989
|
// Writes above reads: a table somebody emptied is worse than one they read.
|
|
828
|
-
|
|
990
|
+
// A looping rule sits with the reads: it breaks the app for everyone it
|
|
991
|
+
// fails for, which matters more than a duplicate or a stray row.
|
|
992
|
+
const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, duplicated: 3, orphaned: 4, privileged: 5 };
|
|
829
993
|
const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
|
|
830
994
|
return described.sort((a, b) => rank(a) - rank(b));
|
|
831
995
|
}
|
package/installer.js
CHANGED
|
@@ -229,8 +229,24 @@ async function uninstall(client, options) {
|
|
|
229
229
|
return removed;
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
/**
|
|
233
|
+
* What the nightly run last said, read out of the database: whether it is
|
|
234
|
+
* installed at all, how it is scheduled, and its newest row in runs - or null
|
|
235
|
+
* for a run that has not happened yet. Read only.
|
|
236
|
+
*/
|
|
237
|
+
async function latestRun(client, options) {
|
|
238
|
+
const where = await status(client, options);
|
|
239
|
+
if (!where) return { installed: false, where: null, run: null };
|
|
240
|
+
const { rows } = await client.query(
|
|
241
|
+
'SELECT ran_at, source, stopped, attacks_run, findings, not_checked FROM ' +
|
|
242
|
+
quote(where.schema) + '.runs ORDER BY ran_at DESC, id DESC LIMIT 1',
|
|
243
|
+
);
|
|
244
|
+
return { installed: true, where: where, run: rows[0] || null };
|
|
245
|
+
}
|
|
246
|
+
|
|
232
247
|
module.exports = {
|
|
233
248
|
SCHEMA: SCHEMA,
|
|
249
|
+
latestRun: latestRun,
|
|
234
250
|
AT: AT,
|
|
235
251
|
install: install,
|
|
236
252
|
uninstall: uninstall,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kryptheon-night",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
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 nightly.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,9 @@
|
|
|
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",
|
|
58
|
+
"check:nightly": "node nightly.check.js",
|
|
56
59
|
"check:cli": "node cli.check.js",
|
|
57
60
|
"check:writes": "node tamper.check.js",
|
|
58
61
|
"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,
|
|
@@ -378,10 +403,14 @@ async function scan(client, sourceSchema, options) {
|
|
|
378
403
|
const ties = finding.authTiesOf(plan);
|
|
379
404
|
// And whether a row says whose it is: with no such column, no rule can
|
|
380
405
|
// tie it to the person asking. Read from the copy, the way seeding did.
|
|
381
|
-
|
|
406
|
+
// The column's name travels too: a team's table that also says who made
|
|
407
|
+
// each row needs it for its prompt, even on a finding that is not about
|
|
408
|
+
// owners at all.
|
|
409
|
+
const owned = new Map(theirs.map((table) => [table.name, attack.ownerColumn(table)]));
|
|
382
410
|
const tagged = (f) => Object.assign({}, f,
|
|
383
411
|
ties.has(f.table) ? { authTied: ties.get(f.table) } : {},
|
|
384
|
-
owned.has(f.table) ? { owned: owned.get(f.table) } : {}
|
|
412
|
+
owned.has(f.table) ? { owned: Boolean(owned.get(f.table)) } : {},
|
|
413
|
+
owned.get(f.table) ? { ownedBy: owned.get(f.table) } : {});
|
|
385
414
|
|
|
386
415
|
return {
|
|
387
416
|
stopped: null,
|
|
@@ -389,7 +418,7 @@ async function scan(client, sourceSchema, options) {
|
|
|
389
418
|
notChecked: notChecked,
|
|
390
419
|
attempted: attempted,
|
|
391
420
|
findings: finding.describeAll(
|
|
392
|
-
impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged).map(tagged),
|
|
421
|
+
impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged, recursive).map(tagged),
|
|
393
422
|
),
|
|
394
423
|
};
|
|
395
424
|
} finally {
|
|
@@ -413,6 +442,51 @@ function exitCodeFor(result) {
|
|
|
413
442
|
return (result.findings || []).some((f) => f.status !== 'verification required') ? 1 : 0;
|
|
414
443
|
}
|
|
415
444
|
|
|
445
|
+
/** Where the nightly run's last answer is kept for the dashboard. */
|
|
446
|
+
function nightlyFile() {
|
|
447
|
+
return store.open(process.cwd()).nightNightly;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* The nightly run's last answer, in the shape the dashboard reads: the same
|
|
452
|
+
* described findings a scan saves, and the three states that are not the same
|
|
453
|
+
* as "nothing found" - not installed, installed and never run, and stopped -
|
|
454
|
+
* said as themselves. `latest` is installer.latestRun's answer.
|
|
455
|
+
*/
|
|
456
|
+
function nightlyRecord(latest, readAt) {
|
|
457
|
+
const at = (value) => (value ? new Date(value).toISOString() : null);
|
|
458
|
+
if (!latest || !latest.installed) return { readAt: readAt, installed: false };
|
|
459
|
+
const where = latest.where || {};
|
|
460
|
+
const record = {
|
|
461
|
+
readAt: readAt,
|
|
462
|
+
installed: true,
|
|
463
|
+
scheduled: where.job ? where.job.schedule : null,
|
|
464
|
+
active: Boolean(where.job && where.job.active),
|
|
465
|
+
source: where.source || null,
|
|
466
|
+
ranAt: null,
|
|
467
|
+
};
|
|
468
|
+
const run = latest.run;
|
|
469
|
+
if (!run) return record;
|
|
470
|
+
return Object.assign(record, {
|
|
471
|
+
ranAt: at(run.ran_at),
|
|
472
|
+
source: run.source,
|
|
473
|
+
stopped: run.stopped || null,
|
|
474
|
+
attacksRun: run.attacks_run || 0,
|
|
475
|
+
notChecked: run.not_checked || [],
|
|
476
|
+
findings: run.stopped ? [] : finding.describeAll(run.findings || []),
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Kept for the dashboard. A failure here never touches the run itself. */
|
|
481
|
+
function saveNightly(file, record) {
|
|
482
|
+
try {
|
|
483
|
+
fs.writeFileSync(file, JSON.stringify(record, null, 2));
|
|
484
|
+
return true;
|
|
485
|
+
} catch (err) {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
416
490
|
/** The last run, or null if there has not been one worth keeping. */
|
|
417
491
|
function loadLastRun(file) {
|
|
418
492
|
try {
|
|
@@ -481,6 +555,34 @@ function notTestedLines(result) {
|
|
|
481
555
|
return lines;
|
|
482
556
|
}
|
|
483
557
|
|
|
558
|
+
/**
|
|
559
|
+
* The warning above the verdict: how much went untested, counted honestly.
|
|
560
|
+
*
|
|
561
|
+
* Every entry used to be counted as a table. Found on a benchmark: one
|
|
562
|
+
* column's duplicate test did not run, and the top line said "1 table was
|
|
563
|
+
* NOT checked" about a table every other attack had covered - and a table
|
|
564
|
+
* with two attacks stuck was counted as two tables. An entry with no attack
|
|
565
|
+
* named is a whole table nothing could be put into; one with an attack named
|
|
566
|
+
* is part of a table.
|
|
567
|
+
*/
|
|
568
|
+
function missedLines(notChecked) {
|
|
569
|
+
const tableOf = (entry) => String(entry.table).split('.')[0];
|
|
570
|
+
const whole = new Set(notChecked.filter((entry) => !entry.key).map(tableOf));
|
|
571
|
+
const parts = notChecked.filter((entry) => entry.key && !whole.has(tableOf(entry)));
|
|
572
|
+
const partly = new Set(parts.map(tableOf));
|
|
573
|
+
const tables = (n) => n + (n === 1 ? ' table' : ' tables');
|
|
574
|
+
const said = [];
|
|
575
|
+
if (whole.size) said.push(tables(whole.size) + (whole.size === 1 ? ' was' : ' were') + ' NOT checked');
|
|
576
|
+
if (parts.length) {
|
|
577
|
+
said.push(parts.length + (parts.length === 1 ? ' check' : ' checks') + ' on ' +
|
|
578
|
+
(whole.size ? tables(partly.size).replace(/ table/, ' other table') : tables(partly.size)) +
|
|
579
|
+
(parts.length === 1 ? ' was' : ' were') + ' NOT run');
|
|
580
|
+
}
|
|
581
|
+
const it = notChecked.length === 1 ? 'it' : 'them';
|
|
582
|
+
return wrap(said.join(', and ') + '. Whatever this report says next is not about ' + it + '. Treat ' +
|
|
583
|
+
it + ' as unknown, not as clear - the list, and why, is at the end.', 68).map((l) => ' ' + l);
|
|
584
|
+
}
|
|
585
|
+
|
|
484
586
|
/** The report, as the person reads it in the morning. */
|
|
485
587
|
function report(result) {
|
|
486
588
|
if (result.stopped) {
|
|
@@ -505,11 +607,7 @@ function report(result) {
|
|
|
505
607
|
const sayWhatWasMissed = () => {
|
|
506
608
|
if (!notChecked.length) return;
|
|
507
609
|
line('');
|
|
508
|
-
|
|
509
|
-
' NOT checked. Whatever this report says next is not');
|
|
510
|
-
line(' about ' + (notChecked.length === 1 ? 'it' : 'them') + '. Treat ' +
|
|
511
|
-
(notChecked.length === 1 ? 'it' : 'them') + ' as unknown, not as clear - the list, and why, is');
|
|
512
|
-
line(' at the end.');
|
|
610
|
+
missedLines(notChecked).forEach(line);
|
|
513
611
|
};
|
|
514
612
|
|
|
515
613
|
if (!result.findings.length) {
|
|
@@ -677,9 +775,13 @@ if (require.main === module) {
|
|
|
677
775
|
module.exports = {
|
|
678
776
|
scan: scan,
|
|
679
777
|
report: report,
|
|
778
|
+
missedLines: missedLines,
|
|
680
779
|
wrap: wrap,
|
|
681
780
|
loadLastRun: loadLastRun,
|
|
682
781
|
saveRun: saveRun,
|
|
782
|
+
nightlyFile: nightlyFile,
|
|
783
|
+
nightlyRecord: nightlyRecord,
|
|
784
|
+
saveNightly: saveNightly,
|
|
683
785
|
sweepOldCopies: sweepOldCopies,
|
|
684
786
|
notTestedLines: notTestedLines,
|
|
685
787
|
lastRunFile: lastRunFile,
|
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,
|
package/store.js
CHANGED
|
@@ -45,6 +45,7 @@ const FILES = {
|
|
|
45
45
|
history: 'history.jsonl',
|
|
46
46
|
testResults: 'test-results',
|
|
47
47
|
nightLast: 'night-last.json',
|
|
48
|
+
nightNightly: 'night-nightly.json',
|
|
48
49
|
codeFindings: 'code-findings.json',
|
|
49
50
|
config: 'config.json',
|
|
50
51
|
snapshot: 'snapshot.json',
|
|
@@ -132,6 +133,7 @@ function pathsIn(dir) {
|
|
|
132
133
|
history: path.join(dir, FILES.history),
|
|
133
134
|
testResults: path.join(dir, FILES.testResults),
|
|
134
135
|
nightLast: path.join(dir, FILES.nightLast),
|
|
136
|
+
nightNightly: path.join(dir, FILES.nightNightly),
|
|
135
137
|
codeFindings: path.join(dir, FILES.codeFindings),
|
|
136
138
|
config: path.join(dir, FILES.config),
|
|
137
139
|
snapshot: path.join(dir, FILES.snapshot),
|
package/trouble.js
CHANGED
|
@@ -359,14 +359,19 @@ function explain(err, raw) {
|
|
|
359
359
|
const ref = code === 'ENOTFOUND' ? supabaseDirectRef(raw) : null;
|
|
360
360
|
if (ref) {
|
|
361
361
|
return [
|
|
362
|
-
'I could not find that server
|
|
362
|
+
'I could not find that server. There are two likely reasons.',
|
|
363
363
|
'',
|
|
364
|
-
'
|
|
365
|
-
'over IPv6. Plenty of home and office networks have no IPv6
|
|
366
|
-
'those the name does not resolve to anything
|
|
367
|
-
'happened here.',
|
|
364
|
+
'Most likely: that address is Supabase’s direct connection, and it now',
|
|
365
|
+
'answers only over IPv6. Plenty of home and office networks have no IPv6',
|
|
366
|
+
'at all, and on those the name does not resolve to anything.',
|
|
368
367
|
'',
|
|
369
|
-
|
|
368
|
+
// A ref with one letter wrong fails exactly the same way, and blaming
|
|
369
|
+
// only IPv6 would send somebody to change networks over a typo.
|
|
370
|
+
'Or the address is mistyped. Check that "' + ref + '" matches the project',
|
|
371
|
+
'ref in Supabase -> Project Settings -> General; one wrong letter fails',
|
|
372
|
+
'exactly the same way.',
|
|
373
|
+
'',
|
|
374
|
+
'Your password has not been tried yet, so it is not the problem. Use the',
|
|
370
375
|
'session pooler instead, which answers over the ordinary internet:',
|
|
371
376
|
'',
|
|
372
377
|
' Supabase dashboard -> Project Settings -> Database ->',
|