kryptheon-night 0.1.14 → 0.1.22
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/bin/kryptheon-night.js +98 -18
- package/finding.js +47 -9
- package/installer.js +16 -0
- package/intro.js +13 -5
- package/package.json +4 -2
- package/scan.js +165 -26
- package/store.js +2 -0
- package/trouble.js +27 -8
package/bin/kryptheon-night.js
CHANGED
|
@@ -32,8 +32,8 @@ const intro = require('../intro.js');
|
|
|
32
32
|
const trouble = require('../trouble.js');
|
|
33
33
|
const { howToConnect } = require('../connect.js');
|
|
34
34
|
const installer = require('../installer.js');
|
|
35
|
-
const finding = require('../finding.js');
|
|
36
35
|
const outside = require('../outside.js');
|
|
36
|
+
const schema = require('../schema.js');
|
|
37
37
|
|
|
38
38
|
const line = (text) => process.stdout.write(text + '\n');
|
|
39
39
|
const fail = (text) => process.stderr.write(text + '\n');
|
|
@@ -88,7 +88,7 @@ function usage() {
|
|
|
88
88
|
// names a schema: `public` is the schema almost everybody has, and a bare
|
|
89
89
|
// word that is not one of these is still read as a schema so that
|
|
90
90
|
// `kryptheon-night public` keeps working.
|
|
91
|
-
const VERBS = ['install', 'uninstall', 'status', 'night'];
|
|
91
|
+
const VERBS = ['install', 'uninstall', 'status', 'night', 'words'];
|
|
92
92
|
|
|
93
93
|
/** What was asked for, and what was left to the default. */
|
|
94
94
|
function readArgs(argv) {
|
|
@@ -118,6 +118,50 @@ 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(record) {
|
|
129
|
+
try {
|
|
130
|
+
scanner.saveNightly(scanner.nightlyFile(), record);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
/* the dashboard keeps its older copy, which carries its own date */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The night's answer, made whole: its findings told with what their tables
|
|
138
|
+
* need for a right fix, plus the functions anyone can call - both read from
|
|
139
|
+
* the shape now, read only. Without the shape it is still the night's answer,
|
|
140
|
+
* just less exact.
|
|
141
|
+
*/
|
|
142
|
+
async function nightRecordFrom(client, latest) {
|
|
143
|
+
let plan = null;
|
|
144
|
+
const run = latest && latest.run;
|
|
145
|
+
if (run && !run.stopped) {
|
|
146
|
+
try {
|
|
147
|
+
plan = await schema.readSchema(client, run.source);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
plan = null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return scanner.nightlyRecord(latest, new Date().toISOString(), plan);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Reads and keeps it, for the doors that do not already hold the answer. */
|
|
156
|
+
async function readAndKeepTheNight(client) {
|
|
157
|
+
try {
|
|
158
|
+
keepTheNight(await nightRecordFrom(client, await installer.latestRun(client, {})));
|
|
159
|
+
} catch (err) {
|
|
160
|
+
/* as above */
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
121
165
|
/**
|
|
122
166
|
* The three doors that are not a scan.
|
|
123
167
|
*
|
|
@@ -127,6 +171,7 @@ function readArgs(argv) {
|
|
|
127
171
|
*/
|
|
128
172
|
async function runVerb(command, client, target) {
|
|
129
173
|
if (command === 'status') {
|
|
174
|
+
await readAndKeepTheNight(client);
|
|
130
175
|
const where = await installer.status(client, {});
|
|
131
176
|
if (!where) {
|
|
132
177
|
line(' The nightly run is not installed in this database.');
|
|
@@ -183,6 +228,7 @@ async function runVerb(command, client, target) {
|
|
|
183
228
|
return 0;
|
|
184
229
|
}
|
|
185
230
|
const removed = await installer.uninstall(client, {});
|
|
231
|
+
await readAndKeepTheNight(client);
|
|
186
232
|
line(' Removed.');
|
|
187
233
|
line('');
|
|
188
234
|
line(' the nightly job ' + (removed.job ? 'gone' : 'there was none'));
|
|
@@ -195,7 +241,10 @@ async function runVerb(command, client, target) {
|
|
|
195
241
|
}
|
|
196
242
|
|
|
197
243
|
if (command === 'night') {
|
|
198
|
-
const
|
|
244
|
+
const latest = await installer.latestRun(client, {});
|
|
245
|
+
const record = await nightRecordFrom(client, latest);
|
|
246
|
+
keepTheNight(record);
|
|
247
|
+
const where = latest.where;
|
|
199
248
|
if (!where) {
|
|
200
249
|
line(' The nightly run is not installed in this database, so there is');
|
|
201
250
|
line(' nothing to read back.');
|
|
@@ -205,31 +254,31 @@ async function runVerb(command, client, target) {
|
|
|
205
254
|
line('');
|
|
206
255
|
return 2;
|
|
207
256
|
}
|
|
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) {
|
|
257
|
+
if (!latest.run) {
|
|
214
258
|
// Installed and never run is not the same as run and found nothing, and
|
|
215
259
|
// saying "nothing got through" here would be the worst sentence in the
|
|
216
260
|
// product printed about a night that never happened.
|
|
217
261
|
line(' It is installed, and it has not run yet.');
|
|
218
262
|
line('');
|
|
219
|
-
line('
|
|
263
|
+
line(' It runs ' + scanner.scheduleTimes(where.job ? where.job.schedule : null) + '.');
|
|
220
264
|
line('');
|
|
221
265
|
return 0;
|
|
222
266
|
}
|
|
223
|
-
const run =
|
|
224
|
-
line(' From the night of ' +
|
|
225
|
-
|
|
267
|
+
const run = latest.run;
|
|
268
|
+
line(' From the night of ' + scanner.bothTimes(run.ran_at) + ', on "' + run.source + '".');
|
|
269
|
+
if (where.job) line(' It runs ' + scanner.scheduleTimes(where.job.schedule) + '.');
|
|
270
|
+
// The one part not from the night: the engine inside the database does
|
|
271
|
+
// not look at functions, so they are read now and said to be.
|
|
272
|
+
if (record.functionsReadAt) {
|
|
273
|
+
line(' Functions anyone can call were looked at just now, not overnight.');
|
|
274
|
+
}
|
|
226
275
|
scanner.report({
|
|
227
|
-
stopped:
|
|
228
|
-
attacksRun:
|
|
229
|
-
notChecked:
|
|
230
|
-
findings:
|
|
276
|
+
stopped: record.stopped,
|
|
277
|
+
attacksRun: record.attacksRun,
|
|
278
|
+
notChecked: record.notChecked || [],
|
|
279
|
+
findings: record.findings || [],
|
|
231
280
|
});
|
|
232
|
-
return run.stopped ? 2 : (
|
|
281
|
+
return run.stopped ? 2 : scanner.exitCodeFor(record);
|
|
233
282
|
}
|
|
234
283
|
|
|
235
284
|
throw new Error('there is no command called ' + command);
|
|
@@ -373,6 +422,23 @@ async function runOutside(appUrl) {
|
|
|
373
422
|
return result.findings.length ? 1 : 0;
|
|
374
423
|
}
|
|
375
424
|
|
|
425
|
+
/** Everything the dashboard needs to say what this command would say. */
|
|
426
|
+
function wordsFor(target, connection) {
|
|
427
|
+
const given = String(connection || '').trim();
|
|
428
|
+
const unusable = given ? trouble.readConnectionString(given) : null;
|
|
429
|
+
return {
|
|
430
|
+
help: intro.whereToFindIt(),
|
|
431
|
+
consent: intro.consentLines(target),
|
|
432
|
+
installConsent: intro.installConsentLines(installer.SCHEMA, target, installer.AT),
|
|
433
|
+
// pg_cron reads this in the database's own time zone, which is UTC on
|
|
434
|
+
// Supabase; the dashboard turns it into the person's local time.
|
|
435
|
+
nightlyAt: installer.AT,
|
|
436
|
+
given: Boolean(given),
|
|
437
|
+
unusable: unusable,
|
|
438
|
+
warning: given && !unusable ? trouble.poolerWarning(given) : null,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
376
442
|
/** Prints a block of plain lines with the indent the rest of the report uses. */
|
|
377
443
|
function block(lines, write) {
|
|
378
444
|
(write || line)('');
|
|
@@ -394,6 +460,17 @@ async function main() {
|
|
|
394
460
|
process.exit(2);
|
|
395
461
|
}
|
|
396
462
|
|
|
463
|
+
// The words this command says, as JSON, for the kryptheon dashboard to show
|
|
464
|
+
// on its own buttons: the help for finding the string, both consent
|
|
465
|
+
// screens, and - when KN_DATABASE_URL is given - what is wrong with it or
|
|
466
|
+
// worth warning about. Never connects, and never prints the string: the
|
|
467
|
+
// warnings name the host at most, and a string that cannot be used is
|
|
468
|
+
// described, not repeated.
|
|
469
|
+
if (asked.command === 'words') {
|
|
470
|
+
process.stdout.write(JSON.stringify(wordsFor(asked.schema || 'public', process.env.KN_DATABASE_URL || '')) + '\n');
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
397
474
|
// The outside attack. Needs no connection string, no copy and no yes: it
|
|
398
475
|
// only asks the live app what it already answers any visitor, and asks for
|
|
399
476
|
// counts, never rows. So nothing below - the string, the consent screen,
|
|
@@ -583,6 +660,9 @@ async function main() {
|
|
|
583
660
|
// think about it again, which is exactly the app that is unprotected
|
|
584
661
|
// three months later. The tool already knows whether the nightly run is
|
|
585
662
|
// there. So it asks.
|
|
663
|
+
// Whatever the nightly run last said is kept for the dashboard too, so
|
|
664
|
+
// the page shows both answers and says which is which.
|
|
665
|
+
await readAndKeepTheNight(client);
|
|
586
666
|
if (!result.stopped) await offerTheNight(client, target, canAsk);
|
|
587
667
|
} catch (err) {
|
|
588
668
|
// Anything that went wrong mid-scan. The copy has already been dropped by
|
package/finding.js
CHANGED
|
@@ -448,7 +448,10 @@ function bodyFor(finding, contents) {
|
|
|
448
448
|
}
|
|
449
449
|
return (
|
|
450
450
|
'Anyone on the internet, without logging in and without an account, can read ' +
|
|
451
|
-
|
|
451
|
+
// Read back the next morning, "just now" would be untrue: it was the
|
|
452
|
+
// nightly run, hours earlier.
|
|
453
|
+
'this table. ' + (finding.fromNight ? 'The nightly run did it on a copy and got back ' : 'I did it myself just now and got back ') +
|
|
454
|
+
finding.readable + ' ' + rowWord +
|
|
452
455
|
(holds ? ', including ' + holds : '') + '.'
|
|
453
456
|
);
|
|
454
457
|
}
|
|
@@ -523,8 +526,32 @@ function serverOnlyLines(finding) {
|
|
|
523
526
|
// column saying whose row it is. The assistant applying it had to correct
|
|
524
527
|
// the prompt; a less careful one would have locked the table away from the
|
|
525
528
|
// members who use it.
|
|
526
|
-
const
|
|
527
|
-
|
|
529
|
+
const team = finding.authTied !== false && (finding.columns || []).find((c) => TENANT_COLUMN.test(String(c)));
|
|
530
|
+
const maker = finding.owner || finding.ownedBy;
|
|
531
|
+
// A team's row that also says who made it - created_by beside workspace_id.
|
|
532
|
+
// Found on a benchmark: the prompt said to compare created_by to auth.uid()
|
|
533
|
+
// for reading, which hides every teammate's rows and breaks the shared
|
|
534
|
+
// view the table exists for. Who made a row decides who may write it
|
|
535
|
+
// under their name, not who may see it.
|
|
536
|
+
if (team && finding.owned !== false && maker) {
|
|
537
|
+
return [
|
|
538
|
+
'Rows in "' + table + '" belong to a team ("' + team + '"), and "' + maker + '" only records who ' +
|
|
539
|
+
'made each one. So reading should be decided by team membership: let a signed-in user read a ' +
|
|
540
|
+
'row when they are a member of that row\'s "' + team + '" - checked through your membership ' +
|
|
541
|
+
'table, or a helper function that asks it - and give logged-out visitors nothing. Do not ' +
|
|
542
|
+
'compare "' + maker + '" to auth.uid() for reading: that hides teammates\' rows from each other ' +
|
|
543
|
+
'and breaks the feature.',
|
|
544
|
+
'',
|
|
545
|
+
'Write separate rules for reading, inserting, updating and deleting. Inserting: WITH CHECK that ' +
|
|
546
|
+
'the user is a member of the row\'s "' + team + '" AND "' + maker + '" = auth.uid(), so nobody ' +
|
|
547
|
+
'writes a row under somebody else\'s name or into a team they are not in. Updating and ' +
|
|
548
|
+
'deleting: the membership check in USING (and WITH CHECK for updating), and decide whether any ' +
|
|
549
|
+
'member may do it or only the row\'s "' + maker + '" or an admin. If only your server writes ' +
|
|
550
|
+
'this table, add no write rules at all.',
|
|
551
|
+
'',
|
|
552
|
+
];
|
|
553
|
+
}
|
|
554
|
+
const tenant = finding.owned === false && team;
|
|
528
555
|
if (tenant) {
|
|
529
556
|
return [
|
|
530
557
|
'Rows in "' + table + '" belong to an organization ("' + tenant + '"), not to one person. So the ' +
|
|
@@ -563,6 +590,18 @@ function serverOnlyLines(finding) {
|
|
|
563
590
|
];
|
|
564
591
|
}
|
|
565
592
|
|
|
593
|
+
/**
|
|
594
|
+
* The opening of a rules fix. "Switch row level security on" is said only
|
|
595
|
+
* when it is off: told it about a table where it was already on, the person
|
|
596
|
+
* looks for a switch that is already flipped and misses the rule that is the
|
|
597
|
+
* real problem.
|
|
598
|
+
*/
|
|
599
|
+
function switchOn(finding) {
|
|
600
|
+
return finding.rlsEnabled
|
|
601
|
+
? 'Row level security is already on for this table, so the rules on it are the problem: '
|
|
602
|
+
: 'Switch row level security on for this table, then ';
|
|
603
|
+
}
|
|
604
|
+
|
|
566
605
|
/**
|
|
567
606
|
* One prompt for everything wrong with one table's rules - read and write,
|
|
568
607
|
* strangers and customers - because it is one rule to replace, not four.
|
|
@@ -597,9 +636,8 @@ function groupPromptFor(members) {
|
|
|
597
636
|
'',
|
|
598
637
|
].concat(
|
|
599
638
|
serverOnlyLines(lead) || [
|
|
600
|
-
|
|
601
|
-
'
|
|
602
|
-
'covers far more than reading.',
|
|
639
|
+
switchOn(lead) + 'remove the rule that lets everyone in, and write separate rules for reading, ' +
|
|
640
|
+
'inserting, updating and deleting. A rule written FOR ALL covers far more than reading.',
|
|
603
641
|
'',
|
|
604
642
|
'Each of them should compare ' + ownerWords + ' to the id of the signed-in user (auth.uid()): ' +
|
|
605
643
|
'USING for reading, updating and deleting, WITH CHECK for inserting and updating, so nobody ' +
|
|
@@ -706,9 +744,9 @@ function fixPromptFor(finding) {
|
|
|
706
744
|
'',
|
|
707
745
|
].concat(
|
|
708
746
|
serverOnlyLines(finding) || [
|
|
709
|
-
|
|
710
|
-
'
|
|
711
|
-
'
|
|
747
|
+
switchOn(finding) + 'write separate rules for reading, inserting, updating and deleting. ' +
|
|
748
|
+
'A rule written FOR SELECT does not cover writes, and a rule written FOR ALL covers ' +
|
|
749
|
+
'far more than reading.',
|
|
712
750
|
'',
|
|
713
751
|
'For insert and update, use WITH CHECK comparing ' + owner +
|
|
714
752
|
' to the id of the signed-in user, so nobody can write a row under somebody ' +
|
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/intro.js
CHANGED
|
@@ -28,15 +28,23 @@ const trouble = require('./trouble.js');
|
|
|
28
28
|
* their users end up on the same page by a different door.
|
|
29
29
|
*/
|
|
30
30
|
function whereToFindIt() {
|
|
31
|
+
// The session pooler, and said as such: the direct connection answers only
|
|
32
|
+
// over IPv6, which many home networks do not have, and the transaction
|
|
33
|
+
// pooler on 6543 cannot hold these attacks together. The kryptheon
|
|
34
|
+
// dashboard shows these same steps; kryptheon-night words hands them over.
|
|
31
35
|
return [
|
|
32
36
|
'Where to find it:',
|
|
33
37
|
'',
|
|
34
|
-
' Supabase supabase.com/dashboard -> your project
|
|
35
|
-
'
|
|
36
|
-
'
|
|
38
|
+
' Supabase 1. supabase.com/dashboard -> open your project.',
|
|
39
|
+
' 2. Press "Connect" at the top of the page.',
|
|
40
|
+
' 3. Choose "Session pooler" - the one on port 5432.',
|
|
41
|
+
' 4. Copy the URI. It starts postgresql://',
|
|
42
|
+
' 5. Replace [YOUR-PASSWORD] in it with your database password.',
|
|
43
|
+
' Forgotten it? Project Settings -> Database -> Reset',
|
|
44
|
+
' database password.',
|
|
37
45
|
'',
|
|
38
|
-
'
|
|
39
|
-
'
|
|
46
|
+
' Not port 6543 (the transaction pooler), and not the direct',
|
|
47
|
+
' db.<project>.supabase.co address - both are warned about.',
|
|
40
48
|
'',
|
|
41
49
|
' Lovable your app uses Supabase underneath. Open the Supabase',
|
|
42
50
|
' and Bolt project it made for you and follow the lines above.',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kryptheon-night",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
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 recursion.check.js && node guarded.check.js && node cli.check.js && node loop.check.js",
|
|
34
|
+
"check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node recursion.check.js && node guarded.check.js && node nightly.check.js && node words.check.js && node 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",
|
|
@@ -55,6 +55,8 @@
|
|
|
55
55
|
"check:privileged": "node privileged.check.js",
|
|
56
56
|
"check:recursion": "node recursion.check.js",
|
|
57
57
|
"check:guarded": "node guarded.check.js",
|
|
58
|
+
"check:nightly": "node nightly.check.js",
|
|
59
|
+
"check:words": "node words.check.js",
|
|
58
60
|
"check:cli": "node cli.check.js",
|
|
59
61
|
"check:writes": "node tamper.check.js",
|
|
60
62
|
"check:orphans": "node orphans.check.js",
|
package/scan.js
CHANGED
|
@@ -385,28 +385,12 @@ async function scan(client, sourceSchema, options) {
|
|
|
385
385
|
// version recorded only the flagged ones, so a revoke made the function
|
|
386
386
|
// vanish from both lists and the fix came back as "could not confirm".
|
|
387
387
|
for (const fn of plan.anonFunctions || []) attempted.push('privileged:' + fn.name);
|
|
388
|
-
|
|
389
|
-
// away before it does anything (schema.guardedAtTheDoor).
|
|
390
|
-
const privileged = (plan.anonFunctions || []).filter((fn) => fn.callable && !fn.aboutCaller && !fn.guarded).map((fn) => ({
|
|
391
|
-
kind: 'privileged',
|
|
392
|
-
fn: fn.name,
|
|
393
|
-
table: fn.name,
|
|
394
|
-
args: fn.args,
|
|
395
|
-
writes: fn.writes,
|
|
396
|
-
hasFixedSearchPath: fn.hasFixedSearchPath,
|
|
397
|
-
columns: [],
|
|
398
|
-
}));
|
|
388
|
+
const privileged = privilegedOf(plan);
|
|
399
389
|
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
const
|
|
404
|
-
// And whether a row says whose it is: with no such column, no rule can
|
|
405
|
-
// tie it to the person asking. Read from the copy, the way seeding did.
|
|
406
|
-
const owned = new Map(theirs.map((table) => [table.name, Boolean(attack.ownerColumn(table))]));
|
|
407
|
-
const tagged = (f) => Object.assign({}, f,
|
|
408
|
-
ties.has(f.table) ? { authTied: ties.get(f.table) } : {},
|
|
409
|
-
owned.has(f.table) ? { owned: owned.get(f.table) } : {});
|
|
390
|
+
// Ties read from the original schema: the copy points auth.users keys at
|
|
391
|
+
// a stand-in, and would call every table untied. Owners read from the
|
|
392
|
+
// copy, the way seeding did.
|
|
393
|
+
const tagged = taggerFor(plan, theirs);
|
|
410
394
|
|
|
411
395
|
return {
|
|
412
396
|
stopped: null,
|
|
@@ -438,6 +422,128 @@ function exitCodeFor(result) {
|
|
|
438
422
|
return (result.findings || []).some((f) => f.status !== 'verification required') ? 1 : 0;
|
|
439
423
|
}
|
|
440
424
|
|
|
425
|
+
/**
|
|
426
|
+
* Functions an anonymous visitor can call that run past the rules. Read from
|
|
427
|
+
* the shape, not attacked. Quiet: a yes/no about the caller, and a function
|
|
428
|
+
* that turns the caller away before it does anything (schema.guardedAtTheDoor).
|
|
429
|
+
*/
|
|
430
|
+
function privilegedOf(plan) {
|
|
431
|
+
return ((plan && plan.anonFunctions) || []).filter((fn) => fn.callable && !fn.aboutCaller && !fn.guarded).map((fn) => ({
|
|
432
|
+
kind: 'privileged',
|
|
433
|
+
fn: fn.name,
|
|
434
|
+
table: fn.name,
|
|
435
|
+
args: fn.args,
|
|
436
|
+
writes: fn.writes,
|
|
437
|
+
hasFixedSearchPath: fn.hasFixedSearchPath,
|
|
438
|
+
columns: [],
|
|
439
|
+
}));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* What each finding needs to know about its table for its fix to be right:
|
|
444
|
+
* whether the app signs people in through Supabase, whether a row says whose
|
|
445
|
+
* it is, and which column that is. Without these a prompt falls back to "the
|
|
446
|
+
* column that says who each row belongs to" - found on a nightly read-back,
|
|
447
|
+
* whose findings came from the database without them.
|
|
448
|
+
*/
|
|
449
|
+
function taggerFor(plan, tables) {
|
|
450
|
+
const ties = finding.authTiesOf(plan);
|
|
451
|
+
const owned = new Map((tables || []).map((table) => [table.name, attack.ownerColumn(table)]));
|
|
452
|
+
return (f) => Object.assign({}, f,
|
|
453
|
+
ties.has(f.table) ? { authTied: ties.get(f.table) } : {},
|
|
454
|
+
owned.has(f.table) ? { owned: Boolean(owned.get(f.table)) } : {},
|
|
455
|
+
owned.get(f.table) ? { ownedBy: owned.get(f.table) } : {});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* What the nightly engine wrote down as not tried, in the shape a scan uses.
|
|
460
|
+
* It names a column's check as table and column apart, with no attack named,
|
|
461
|
+
* so "export_jobs.access_token could not be raced" was counted as a whole
|
|
462
|
+
* table not checked - and listed without its column.
|
|
463
|
+
*/
|
|
464
|
+
function notCheckedFromNight(entries) {
|
|
465
|
+
return (entries || []).map((e) => {
|
|
466
|
+
if (!e || e.key || !e.column) return e;
|
|
467
|
+
const kind = /two requests at the same instant/.test(String(e.why)) ? 'duplicated' : 'orphaned';
|
|
468
|
+
return { table: e.table + '.' + e.column, key: kind + ':' + e.table + ':' + e.column, why: e.why };
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* A moment of the night, said in UTC - which is what the database runs on -
|
|
474
|
+
* and in this computer's own time, so 03:00 is not read as three at night
|
|
475
|
+
* here. Found on a real read-back: "the night of 2026-10-01 03:00" was 08:30
|
|
476
|
+
* in India, and nothing said so.
|
|
477
|
+
*/
|
|
478
|
+
function bothTimes(iso) {
|
|
479
|
+
const at = new Date(iso);
|
|
480
|
+
return at.toISOString().slice(0, 16).replace('T', ' ') + ' UTC (' +
|
|
481
|
+
at.toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) + ' your time)';
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** "0 3 * * *" as a time of day in both, or the schedule as written. */
|
|
485
|
+
function scheduleTimes(cron) {
|
|
486
|
+
const m = /^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*$/.exec(String(cron || '').trim());
|
|
487
|
+
if (!m) return String(cron || 'whenever it is scheduled');
|
|
488
|
+
const now = new Date();
|
|
489
|
+
const at = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), Number(m[2]), Number(m[1])));
|
|
490
|
+
return 'every night at ' + at.toISOString().slice(11, 16) + ' UTC (' +
|
|
491
|
+
at.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) + ' your time)';
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Where the nightly run's last answer is kept for the dashboard. */
|
|
495
|
+
function nightlyFile() {
|
|
496
|
+
return store.open(process.cwd()).nightNightly;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The nightly run's last answer, in the shape the dashboard reads: the same
|
|
501
|
+
* described findings a scan saves, and the three states that are not the same
|
|
502
|
+
* as "nothing found" - not installed, installed and never run, and stopped -
|
|
503
|
+
* said as themselves. `latest` is installer.latestRun's answer.
|
|
504
|
+
*/
|
|
505
|
+
function nightlyRecord(latest, readAt, plan) {
|
|
506
|
+
const at = (value) => (value ? new Date(value).toISOString() : null);
|
|
507
|
+
if (!latest || !latest.installed) return { readAt: readAt, installed: false };
|
|
508
|
+
const where = latest.where || {};
|
|
509
|
+
const record = {
|
|
510
|
+
readAt: readAt,
|
|
511
|
+
installed: true,
|
|
512
|
+
scheduled: where.job ? where.job.schedule : null,
|
|
513
|
+
active: Boolean(where.job && where.job.active),
|
|
514
|
+
source: where.source || null,
|
|
515
|
+
ranAt: null,
|
|
516
|
+
};
|
|
517
|
+
const run = latest.run;
|
|
518
|
+
if (!run) return record;
|
|
519
|
+
// The night's findings, told as the night's: tagged with what their table
|
|
520
|
+
// needs for a right fix when the schema could be read, and joined by the
|
|
521
|
+
// functions anyone can call - read now, when this is asked, because the
|
|
522
|
+
// engine inside the database does not look at them.
|
|
523
|
+
const tag = plan ? taggerFor(plan, plan.tables) : (f) => f;
|
|
524
|
+
const night = (run.findings || []).map((f) => tag(Object.assign({}, f, { fromNight: true })));
|
|
525
|
+
const now = plan ? privilegedOf(plan).map(tag) : [];
|
|
526
|
+
return Object.assign(record, {
|
|
527
|
+
ranAt: at(run.ran_at),
|
|
528
|
+
source: run.source,
|
|
529
|
+
stopped: run.stopped || null,
|
|
530
|
+
attacksRun: run.attacks_run || 0,
|
|
531
|
+
notChecked: notCheckedFromNight(run.not_checked),
|
|
532
|
+
findings: run.stopped ? [] : finding.describeAll(night.concat(now)),
|
|
533
|
+
functionsReadAt: plan ? readAt : null,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Kept for the dashboard. A failure here never touches the run itself. */
|
|
538
|
+
function saveNightly(file, record) {
|
|
539
|
+
try {
|
|
540
|
+
fs.writeFileSync(file, JSON.stringify(record, null, 2));
|
|
541
|
+
return true;
|
|
542
|
+
} catch (err) {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
441
547
|
/** The last run, or null if there has not been one worth keeping. */
|
|
442
548
|
function loadLastRun(file) {
|
|
443
549
|
try {
|
|
@@ -506,6 +612,34 @@ function notTestedLines(result) {
|
|
|
506
612
|
return lines;
|
|
507
613
|
}
|
|
508
614
|
|
|
615
|
+
/**
|
|
616
|
+
* The warning above the verdict: how much went untested, counted honestly.
|
|
617
|
+
*
|
|
618
|
+
* Every entry used to be counted as a table. Found on a benchmark: one
|
|
619
|
+
* column's duplicate test did not run, and the top line said "1 table was
|
|
620
|
+
* NOT checked" about a table every other attack had covered - and a table
|
|
621
|
+
* with two attacks stuck was counted as two tables. An entry with no attack
|
|
622
|
+
* named is a whole table nothing could be put into; one with an attack named
|
|
623
|
+
* is part of a table.
|
|
624
|
+
*/
|
|
625
|
+
function missedLines(notChecked) {
|
|
626
|
+
const tableOf = (entry) => String(entry.table).split('.')[0];
|
|
627
|
+
const whole = new Set(notChecked.filter((entry) => !entry.key).map(tableOf));
|
|
628
|
+
const parts = notChecked.filter((entry) => entry.key && !whole.has(tableOf(entry)));
|
|
629
|
+
const partly = new Set(parts.map(tableOf));
|
|
630
|
+
const tables = (n) => n + (n === 1 ? ' table' : ' tables');
|
|
631
|
+
const said = [];
|
|
632
|
+
if (whole.size) said.push(tables(whole.size) + (whole.size === 1 ? ' was' : ' were') + ' NOT checked');
|
|
633
|
+
if (parts.length) {
|
|
634
|
+
said.push(parts.length + (parts.length === 1 ? ' check' : ' checks') + ' on ' +
|
|
635
|
+
(whole.size ? tables(partly.size).replace(/ table/, ' other table') : tables(partly.size)) +
|
|
636
|
+
(parts.length === 1 ? ' was' : ' were') + ' NOT run');
|
|
637
|
+
}
|
|
638
|
+
const it = notChecked.length === 1 ? 'it' : 'them';
|
|
639
|
+
return wrap(said.join(', and ') + '. Whatever this report says next is not about ' + it + '. Treat ' +
|
|
640
|
+
it + ' as unknown, not as clear - the list, and why, is at the end.', 68).map((l) => ' ' + l);
|
|
641
|
+
}
|
|
642
|
+
|
|
509
643
|
/** The report, as the person reads it in the morning. */
|
|
510
644
|
function report(result) {
|
|
511
645
|
if (result.stopped) {
|
|
@@ -530,11 +664,7 @@ function report(result) {
|
|
|
530
664
|
const sayWhatWasMissed = () => {
|
|
531
665
|
if (!notChecked.length) return;
|
|
532
666
|
line('');
|
|
533
|
-
|
|
534
|
-
' NOT checked. Whatever this report says next is not');
|
|
535
|
-
line(' about ' + (notChecked.length === 1 ? 'it' : 'them') + '. Treat ' +
|
|
536
|
-
(notChecked.length === 1 ? 'it' : 'them') + ' as unknown, not as clear - the list, and why, is');
|
|
537
|
-
line(' at the end.');
|
|
667
|
+
missedLines(notChecked).forEach(line);
|
|
538
668
|
};
|
|
539
669
|
|
|
540
670
|
if (!result.findings.length) {
|
|
@@ -702,9 +832,18 @@ if (require.main === module) {
|
|
|
702
832
|
module.exports = {
|
|
703
833
|
scan: scan,
|
|
704
834
|
report: report,
|
|
835
|
+
missedLines: missedLines,
|
|
705
836
|
wrap: wrap,
|
|
706
837
|
loadLastRun: loadLastRun,
|
|
707
838
|
saveRun: saveRun,
|
|
839
|
+
nightlyFile: nightlyFile,
|
|
840
|
+
nightlyRecord: nightlyRecord,
|
|
841
|
+
bothTimes: bothTimes,
|
|
842
|
+
scheduleTimes: scheduleTimes,
|
|
843
|
+
notCheckedFromNight: notCheckedFromNight,
|
|
844
|
+
privilegedOf: privilegedOf,
|
|
845
|
+
taggerFor: taggerFor,
|
|
846
|
+
saveNightly: saveNightly,
|
|
708
847
|
sweepOldCopies: sweepOldCopies,
|
|
709
848
|
notTestedLines: notTestedLines,
|
|
710
849
|
lastRunFile: lastRunFile,
|
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
|
@@ -189,8 +189,22 @@ function poolerWarning(raw) {
|
|
|
189
189
|
'That is the transaction pooler (port 6543). It gives every statement a',
|
|
190
190
|
'different connection, and these attacks have to stay on one.',
|
|
191
191
|
'',
|
|
192
|
-
'Use the session pooler
|
|
193
|
-
'
|
|
192
|
+
'Use the session pooler instead: Supabase -> Connect -> Session pooler,',
|
|
193
|
+
'the one whose port is 5432.',
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The direct connection answers only over IPv6 now. On a network without
|
|
198
|
+
// it the name does not resolve, and the failure reads like a typo. It works
|
|
199
|
+
// where IPv6 does, so this warns rather than refuses.
|
|
200
|
+
if (/^db\.[a-z0-9]+\.supabase\.(co|com)$/i.test(url.hostname)) {
|
|
201
|
+
return [
|
|
202
|
+
'That is Supabase\'s direct connection (db.' + url.hostname.split('.')[1] + '.supabase.co). It',
|
|
203
|
+
'answers only over IPv6, and many home and office networks have none - it',
|
|
204
|
+
'then fails as if the address were wrong.',
|
|
205
|
+
'',
|
|
206
|
+
'If it does, use the session pooler: Supabase -> Connect -> Session pooler,',
|
|
207
|
+
'port 5432.',
|
|
194
208
|
];
|
|
195
209
|
}
|
|
196
210
|
|
|
@@ -359,14 +373,19 @@ function explain(err, raw) {
|
|
|
359
373
|
const ref = code === 'ENOTFOUND' ? supabaseDirectRef(raw) : null;
|
|
360
374
|
if (ref) {
|
|
361
375
|
return [
|
|
362
|
-
'I could not find that server
|
|
376
|
+
'I could not find that server. There are two likely reasons.',
|
|
377
|
+
'',
|
|
378
|
+
'Most likely: that address is Supabase’s direct connection, and it now',
|
|
379
|
+
'answers only over IPv6. Plenty of home and office networks have no IPv6',
|
|
380
|
+
'at all, and on those the name does not resolve to anything.',
|
|
363
381
|
'',
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
'
|
|
367
|
-
'
|
|
382
|
+
// A ref with one letter wrong fails exactly the same way, and blaming
|
|
383
|
+
// only IPv6 would send somebody to change networks over a typo.
|
|
384
|
+
'Or the address is mistyped. Check that "' + ref + '" matches the project',
|
|
385
|
+
'ref in Supabase -> Project Settings -> General; one wrong letter fails',
|
|
386
|
+
'exactly the same way.',
|
|
368
387
|
'',
|
|
369
|
-
'
|
|
388
|
+
'Your password has not been tried yet, so it is not the problem. Use the',
|
|
370
389
|
'session pooler instead, which answers over the ordinary internet:',
|
|
371
390
|
'',
|
|
372
391
|
' Supabase dashboard -> Project Settings -> Database ->',
|