kryptheon-night 0.1.6 → 0.1.7
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/README.md +21 -0
- package/bin/kryptheon-night.js +61 -2
- package/finding.js +16 -0
- package/outside.js +409 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -37,6 +37,27 @@ If you paste the wrong thing — the project URL, an API key, the line with
|
|
|
37
37
|
`[YOUR-PASSWORD]` still in it — it says so and tells you where the right one
|
|
38
38
|
is. It does not just fail.
|
|
39
39
|
|
|
40
|
+
## No connection string? Check your app from outside
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
npx kryptheon-night https://your-app.example.com
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Give it your running app instead. It reads the app the way a stranger's
|
|
47
|
+
browser does — the Supabase address and public key every visitor is already
|
|
48
|
+
handed — and asks each table your app uses one question: can a stranger read
|
|
49
|
+
this? It asks for a **count only**, so no customer's row is ever pulled out,
|
|
50
|
+
and it writes nothing. About ten seconds, no password, no yes to give.
|
|
51
|
+
|
|
52
|
+
It is a narrower check than the full scan, and it says so:
|
|
53
|
+
|
|
54
|
+
- It only proves a leak when a stranger actually gets rows back. A table that
|
|
55
|
+
answers with none looks the same whether it is well protected or open and
|
|
56
|
+
empty, so it is reported as "could not tell", never as safe.
|
|
57
|
+
- One customer reading another's rows, adding or deleting rows, and the same
|
|
58
|
+
thing saved twice at once all change data, so they are only ever tried on
|
|
59
|
+
a copy — the full scan above.
|
|
60
|
+
|
|
40
61
|
## What you get back
|
|
41
62
|
|
|
42
63
|
Four questions, asked against the copy:
|
package/bin/kryptheon-night.js
CHANGED
|
@@ -32,6 +32,7 @@ const trouble = require('../trouble.js');
|
|
|
32
32
|
const { howToConnect } = require('../connect.js');
|
|
33
33
|
const installer = require('../installer.js');
|
|
34
34
|
const finding = require('../finding.js');
|
|
35
|
+
const outside = require('../outside.js');
|
|
35
36
|
|
|
36
37
|
const line = (text) => process.stdout.write(text + '\n');
|
|
37
38
|
const fail = (text) => process.stderr.write(text + '\n');
|
|
@@ -45,6 +46,12 @@ function usage() {
|
|
|
45
46
|
'',
|
|
46
47
|
' npx kryptheon-night',
|
|
47
48
|
'',
|
|
49
|
+
' No connection string? Give it your running app instead. It reads the app',
|
|
50
|
+
' the way a stranger does and tells you which tables anyone can read -',
|
|
51
|
+
' counts only, nothing written:',
|
|
52
|
+
'',
|
|
53
|
+
' npx kryptheon-night https://your-app.example.com',
|
|
54
|
+
'',
|
|
48
55
|
' Run it again after you fix something and it tells you what is actually',
|
|
49
56
|
' closed - you do not have to ask it to. It also offers to do the same',
|
|
50
57
|
' check every night from inside your database, and says so if it already',
|
|
@@ -85,11 +92,15 @@ const VERBS = ['install', 'uninstall', 'status', 'night'];
|
|
|
85
92
|
/** What was asked for, and what was left to the default. */
|
|
86
93
|
function readArgs(argv) {
|
|
87
94
|
const asked = {
|
|
88
|
-
command: 'scan', schema: null, recheck: false, yes: false, help: false, unknown: [],
|
|
95
|
+
command: 'scan', schema: null, appUrl: null, recheck: false, yes: false, help: false, unknown: [],
|
|
89
96
|
};
|
|
90
97
|
for (let i = 0; i < argv.length; i++) {
|
|
91
98
|
const arg = argv[i];
|
|
92
|
-
|
|
99
|
+
// An address is the outside attack: the running app, read the way a
|
|
100
|
+
// stranger's browser reads it. Never a schema name - no schema is called
|
|
101
|
+
// https://anything.
|
|
102
|
+
if (/^https?:\/\//i.test(arg) && !asked.appUrl) asked.appUrl = arg;
|
|
103
|
+
else if (arg === '--recheck') asked.recheck = true;
|
|
93
104
|
else if (arg === '--yes' || arg === '-y') asked.yes = true;
|
|
94
105
|
else if (arg === '--help' || arg === '-h') asked.help = true;
|
|
95
106
|
else if (arg === '--schema') asked.schema = argv[++i] || null;
|
|
@@ -322,6 +333,45 @@ async function offerTheNight(client, target, canAsk) {
|
|
|
322
333
|
}
|
|
323
334
|
}
|
|
324
335
|
|
|
336
|
+
/**
|
|
337
|
+
* The attack from outside, end to end: find the backend in the app, ask each
|
|
338
|
+
* table as a stranger, say what came back and what could not be tested.
|
|
339
|
+
*
|
|
340
|
+
* Exit codes are the same three as the scan: 1 when a stranger read rows, 0
|
|
341
|
+
* when nothing came back, 2 when it could not run at all.
|
|
342
|
+
*/
|
|
343
|
+
async function runOutside(appUrl) {
|
|
344
|
+
line('');
|
|
345
|
+
line(' Kryptheon Night Shift - from outside');
|
|
346
|
+
line(' I read your app the way a stranger\'s browser does, and ask your');
|
|
347
|
+
line(' database what it lets a stranger see. Counts only, nothing written.');
|
|
348
|
+
line('');
|
|
349
|
+
line(' Reading ' + appUrl + ' ...');
|
|
350
|
+
|
|
351
|
+
let found;
|
|
352
|
+
try {
|
|
353
|
+
found = await outside.discover(appUrl);
|
|
354
|
+
} catch (err) {
|
|
355
|
+
fail('');
|
|
356
|
+
fail(' ' + (err.code ? err.message : 'I could not read ' + appUrl + ': ' + err.message));
|
|
357
|
+
fail('');
|
|
358
|
+
return 2;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
let result;
|
|
362
|
+
try {
|
|
363
|
+
result = await outside.prowl(found);
|
|
364
|
+
} catch (err) {
|
|
365
|
+
fail('');
|
|
366
|
+
fail(' I found your database but could not ask it anything: ' + err.message);
|
|
367
|
+
fail('');
|
|
368
|
+
return 2;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
outside.reportLines(found, result).forEach(line);
|
|
372
|
+
return result.findings.length ? 1 : 0;
|
|
373
|
+
}
|
|
374
|
+
|
|
325
375
|
/** Prints a block of plain lines with the indent the rest of the report uses. */
|
|
326
376
|
function block(lines, write) {
|
|
327
377
|
(write || line)('');
|
|
@@ -343,6 +393,15 @@ async function main() {
|
|
|
343
393
|
process.exit(2);
|
|
344
394
|
}
|
|
345
395
|
|
|
396
|
+
// The outside attack. Needs no connection string, no copy and no yes: it
|
|
397
|
+
// only asks the live app what it already answers any visitor, and asks for
|
|
398
|
+
// counts, never rows. So nothing below - the string, the consent screen,
|
|
399
|
+
// the connection - applies to it.
|
|
400
|
+
if (asked.appUrl) {
|
|
401
|
+
process.exitCode = await runOutside(asked.appUrl);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
346
405
|
line('');
|
|
347
406
|
line(' Kryptheon Night Shift');
|
|
348
407
|
line(' I attack a copy of your database and tell you what got in.');
|
package/finding.js
CHANGED
|
@@ -312,6 +312,19 @@ function bodyFor(finding, contents) {
|
|
|
312
312
|
}
|
|
313
313
|
|
|
314
314
|
if (finding.kind === 'exposed') {
|
|
315
|
+
// Two different proofs, said as two different things. The copy-based scan
|
|
316
|
+
// signed in with the anon key and pulled the rows out; the outside check
|
|
317
|
+
// (outside.js) asked the live app for the count alone and never pulled a
|
|
318
|
+
// row, so it may not claim it did.
|
|
319
|
+
if (finding.countOnly) {
|
|
320
|
+
const many = finding.readable === 1 ? 'is 1 row' : 'are ' + finding.readable + ' rows';
|
|
321
|
+
return (
|
|
322
|
+
'Anyone on the internet, without logging in and without an account, can read ' +
|
|
323
|
+
'this table. I asked your live app as a stranger and it let me - there ' + many +
|
|
324
|
+
' in it. I asked only for the count, so I never pulled a row out; a real ' +
|
|
325
|
+
'attacker would have taken them.'
|
|
326
|
+
);
|
|
327
|
+
}
|
|
315
328
|
return (
|
|
316
329
|
'Anyone on the internet, without logging in and without an account, can read ' +
|
|
317
330
|
'this table. I did it myself just now and got back ' + finding.readable + ' ' + rowWord +
|
|
@@ -476,6 +489,9 @@ function describe(finding) {
|
|
|
476
489
|
proof: finding.kind === 'duplicated'
|
|
477
490
|
? 'I created ' + finding.copies + ' rows in "' + finding.table + '" holding the same ' +
|
|
478
491
|
finding.column + '.'
|
|
492
|
+
: finding.countOnly
|
|
493
|
+
? 'I counted ' + finding.readable + ' ' + (finding.readable === 1 ? 'row' : 'rows') +
|
|
494
|
+
' in "' + finding.table + '" that a stranger can read, without pulling any of them out.'
|
|
479
495
|
: 'I read ' + finding.readable + ' ' + (finding.readable === 1 ? 'row' : 'rows') +
|
|
480
496
|
' from "' + finding.table + '" that should not have been readable.',
|
|
481
497
|
fixPrompt: fixPromptFor(finding),
|
package/outside.js
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// The attack that needs nothing but what the app already hands every visitor.
|
|
2
|
+
//
|
|
3
|
+
// The deep scan (scan.js) needs the database connection string - the password
|
|
4
|
+
// to the whole database - and copies the database so it can attack the copy.
|
|
5
|
+
// Most people building on Lovable, Bolt, v0 or a bare Supabase project never
|
|
6
|
+
// have that string: the platform keeps it. What they DO ship, to every browser
|
|
7
|
+
// that opens the app, is the database's public address and its "publishable"
|
|
8
|
+
// (anon) key. Those two are all a real attacker outside has, and they are all
|
|
9
|
+
// this needs.
|
|
10
|
+
//
|
|
11
|
+
// So this becomes that attacker. With the anon key, for each table the app
|
|
12
|
+
// talks to, it asks PostgREST one question: can a stranger read this table? It
|
|
13
|
+
// asks for the COUNT only - never a row - so it learns "anyone can read your
|
|
14
|
+
// bookings table, and there are 3,412 of them" without ever pulling a
|
|
15
|
+
// customer's name or email out of the live database. Reading real rows to
|
|
16
|
+
// prove a leak would be committing the leak.
|
|
17
|
+
//
|
|
18
|
+
// WHAT THIS DELIBERATELY DOES NOT DO
|
|
19
|
+
//
|
|
20
|
+
// It never writes. It never signs in as one customer to read another's rows,
|
|
21
|
+
// never races two writes, never deletes a row to see if it can. Those all
|
|
22
|
+
// change data, and this runs against the live app, not a copy. They are the
|
|
23
|
+
// copy-based scan's job, and they need the connection string. What this cannot
|
|
24
|
+
// reach, the report says out loud - so a quiet result is never mistaken for a
|
|
25
|
+
// safe app.
|
|
26
|
+
|
|
27
|
+
const finding = require('./finding.js');
|
|
28
|
+
|
|
29
|
+
// Wraps one paragraph to a width that reads in a terminal and a chat box, the
|
|
30
|
+
// same width the deep scan's report uses.
|
|
31
|
+
function wrap(text, width) {
|
|
32
|
+
const words = String(text).split(/\s+/).filter(Boolean);
|
|
33
|
+
const out = [];
|
|
34
|
+
let current = '';
|
|
35
|
+
for (const word of words) {
|
|
36
|
+
if ((current + ' ' + word).trim().length > (width || 70)) {
|
|
37
|
+
out.push(current.trim());
|
|
38
|
+
current = word;
|
|
39
|
+
} else {
|
|
40
|
+
current = (current + ' ' + word).trim();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (current) out.push(current.trim());
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// PostgREST answers a count-only request with an empty body and the total in a
|
|
48
|
+
// Content-Range header. `limit=0` is what makes the body empty: the query
|
|
49
|
+
// returns no rows at all, so not one leaves the database, and count=exact
|
|
50
|
+
// still fills in the total.
|
|
51
|
+
|
|
52
|
+
/** The REST base for a Supabase project URL. */
|
|
53
|
+
function restBase(supabaseUrl) {
|
|
54
|
+
return String(supabaseUrl || '').replace(/\/+$/, '') + '/rest/v1';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The Supabase address, the publishable key, and the tables the app talks to,
|
|
59
|
+
* read out of the app's own JavaScript - exactly what a browser is handed.
|
|
60
|
+
*
|
|
61
|
+
* Given `{ supabaseUrl, key, tables }` already, it trusts them and does no
|
|
62
|
+
* fetching. Otherwise it needs the running app's address and reads the rest
|
|
63
|
+
* from there.
|
|
64
|
+
*/
|
|
65
|
+
async function discover(input, options) {
|
|
66
|
+
const opts = options || {};
|
|
67
|
+
const doFetch = opts.fetch || fetch;
|
|
68
|
+
|
|
69
|
+
if (input && input.supabaseUrl && input.key) {
|
|
70
|
+
return {
|
|
71
|
+
supabaseUrl: input.supabaseUrl,
|
|
72
|
+
key: input.key,
|
|
73
|
+
tables: dedupe(input.tables || []),
|
|
74
|
+
from: 'given',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const appUrl = typeof input === 'string' ? input : input && input.appUrl;
|
|
79
|
+
if (!appUrl) throw problem('no-app', 'I need the address of your running app, e.g. https://your-app.example.com');
|
|
80
|
+
|
|
81
|
+
const html = await getText(doFetch, appUrl);
|
|
82
|
+
const scripts = scriptUrls(html, appUrl);
|
|
83
|
+
// The HTML itself sometimes carries the values (server-rendered apps do), so
|
|
84
|
+
// it is searched too, before the bundles.
|
|
85
|
+
let text = html;
|
|
86
|
+
for (const src of scripts) {
|
|
87
|
+
text += '\n' + await getText(doFetch, src).catch(() => '');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const supabaseUrl = firstMatch(text, /https?:\/\/[a-z0-9-]+\.supabase\.co/i);
|
|
91
|
+
const key = firstMatch(text, /sb_publishable_[A-Za-z0-9_-]{10,}/) || anonJwt(text);
|
|
92
|
+
if (!supabaseUrl || !key) {
|
|
93
|
+
throw problem(
|
|
94
|
+
'no-backend',
|
|
95
|
+
'I could not find a Supabase address and public key in ' + appUrl + '. ' +
|
|
96
|
+
'If this app has no database, there is nothing here to read; if it does, ' +
|
|
97
|
+
'point me at the page that actually loads it.',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return { supabaseUrl: supabaseUrl, key: key, tables: tableNames(text), from: 'app', appUrl: appUrl };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Can a stranger read this table? Asked for the count alone, so no row is ever
|
|
105
|
+
* pulled out of the live database.
|
|
106
|
+
*
|
|
107
|
+
* Readable, protected, absent, or unclear - never guessed. A 200 means the
|
|
108
|
+
* anon key was allowed to read it; a 401 or 403 means row level security
|
|
109
|
+
* turned it away; a 404 means the table is not exposed through the API at all.
|
|
110
|
+
*/
|
|
111
|
+
async function readable(restUrl, key, table, options) {
|
|
112
|
+
const opts = options || {};
|
|
113
|
+
const doFetch = opts.fetch || fetch;
|
|
114
|
+
const url = restUrl + '/' + encodeURIComponent(table) + '?select=*&limit=0';
|
|
115
|
+
let res;
|
|
116
|
+
try {
|
|
117
|
+
res = await doFetch(url, {
|
|
118
|
+
method: 'GET',
|
|
119
|
+
headers: { apikey: key, Authorization: 'Bearer ' + key, Prefer: 'count=exact' },
|
|
120
|
+
});
|
|
121
|
+
} catch (err) {
|
|
122
|
+
return { table: table, status: 0, verdict: 'unclear', why: 'the request could not be made: ' + err.message };
|
|
123
|
+
}
|
|
124
|
+
// The body is read and dropped without being kept, so even a server that
|
|
125
|
+
// ignores the count-only request never leaves a customer's row in memory
|
|
126
|
+
// here longer than it takes to throw it away.
|
|
127
|
+
await res.text().catch(() => '');
|
|
128
|
+
|
|
129
|
+
// The one distinction that matters, and the one it is easy to get wrong.
|
|
130
|
+
//
|
|
131
|
+
// 200 with a count above zero -> a stranger really did read real rows.
|
|
132
|
+
// Proven. This is the finding.
|
|
133
|
+
// 200 with a count of zero -> a stranger was allowed to ask, and got
|
|
134
|
+
// nothing. That is ALSO exactly what a
|
|
135
|
+
// correctly protected table returns to a
|
|
136
|
+
// stranger - row level security filters the
|
|
137
|
+
// rows, it does not refuse the request. So
|
|
138
|
+
// an empty answer proves nothing either way,
|
|
139
|
+
// and must never be reported as a leak.
|
|
140
|
+
// 401 or 403 -> the table's own grants turned the key
|
|
141
|
+
// away before any rule ran.
|
|
142
|
+
// 404 -> not exposed through the API at all.
|
|
143
|
+
if (res.status === 200 || res.status === 206) {
|
|
144
|
+
const count = rangeCount(res.headers);
|
|
145
|
+
if (count && count > 0) return { table: table, status: res.status, verdict: 'readable', count: count };
|
|
146
|
+
// Allowed to ask, nothing came back. Cannot be told from good security
|
|
147
|
+
// with no rows in the table. The deep scan, which seeds its own rows,
|
|
148
|
+
// settles it; from outside it stays open.
|
|
149
|
+
return { table: table, status: res.status, verdict: 'empty', count: count == null ? 0 : count };
|
|
150
|
+
}
|
|
151
|
+
if (res.status === 401 || res.status === 403) {
|
|
152
|
+
return { table: table, status: res.status, verdict: 'protected' };
|
|
153
|
+
}
|
|
154
|
+
if (res.status === 404) {
|
|
155
|
+
return { table: table, status: res.status, verdict: 'absent' };
|
|
156
|
+
}
|
|
157
|
+
return { table: table, status: res.status, verdict: 'unclear' };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Every table the app names, asked the one question, turned into the same kind
|
|
162
|
+
* of finding scan.js produces - so `finding.describeAll` reports both the same
|
|
163
|
+
* way. The count-only proof is carried through so the wording never claims a
|
|
164
|
+
* row was pulled that was not.
|
|
165
|
+
*/
|
|
166
|
+
async function prowl(target, options) {
|
|
167
|
+
const opts = options || {};
|
|
168
|
+
const tables = dedupe(target.tables || []);
|
|
169
|
+
if (!tables.length) {
|
|
170
|
+
return {
|
|
171
|
+
findings: [], attempted: [], protectedTables: [], emptyTables: [], absent: [], unclear: [],
|
|
172
|
+
note: 'the app did not name any tables I could find, so I had nothing to ask about',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const restUrl = restBase(target.supabaseUrl);
|
|
176
|
+
const findings = [];
|
|
177
|
+
const attempted = [];
|
|
178
|
+
const protectedTables = [];
|
|
179
|
+
const emptyTables = [];
|
|
180
|
+
const absent = [];
|
|
181
|
+
const unclear = [];
|
|
182
|
+
|
|
183
|
+
for (const table of tables) {
|
|
184
|
+
const seen = await readable(restUrl, target.key, table, opts);
|
|
185
|
+
attempted.push(table);
|
|
186
|
+
if (seen.verdict === 'readable') {
|
|
187
|
+
findings.push({
|
|
188
|
+
kind: 'exposed',
|
|
189
|
+
table: table,
|
|
190
|
+
readable: seen.count,
|
|
191
|
+
countOnly: true, // read over the network, count only - see finding.js
|
|
192
|
+
columns: [], // no row was pulled, so nothing is claimed about contents
|
|
193
|
+
who: 'anyone',
|
|
194
|
+
rlsEnabled: null,
|
|
195
|
+
});
|
|
196
|
+
} else if (seen.verdict === 'protected') {
|
|
197
|
+
protectedTables.push(table);
|
|
198
|
+
} else if (seen.verdict === 'empty') {
|
|
199
|
+
// Allowed to ask, nothing came back. Not proof of a leak, and not proof
|
|
200
|
+
// of safety either - carried separately so the report can say plainly
|
|
201
|
+
// that this one could not be settled from outside.
|
|
202
|
+
emptyTables.push(table);
|
|
203
|
+
} else if (seen.verdict === 'absent') {
|
|
204
|
+
absent.push(table);
|
|
205
|
+
} else {
|
|
206
|
+
unclear.push({ table: table, why: seen.why || ('the server answered ' + seen.status) });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
findings: findings, attempted: attempted, protectedTables: protectedTables,
|
|
211
|
+
emptyTables: emptyTables, absent: absent, unclear: unclear,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* -------------------------------------------------------------------------- */
|
|
216
|
+
|
|
217
|
+
function problem(code, message) {
|
|
218
|
+
const err = new Error(message);
|
|
219
|
+
err.code = code;
|
|
220
|
+
return err;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function getText(doFetch, url) {
|
|
224
|
+
const res = await doFetch(url, { headers: { 'user-agent': 'kryptheon-night' } });
|
|
225
|
+
if (!res.ok) throw problem('unreachable', 'I could not read ' + url + ' (it answered ' + res.status + ')');
|
|
226
|
+
return res.text();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// <script src="/assets/index-xxxx.js"> and the module-preload links, made
|
|
230
|
+
// absolute against the app's own address.
|
|
231
|
+
function scriptUrls(html, appUrl) {
|
|
232
|
+
const out = [];
|
|
233
|
+
const re = /(?:src|href)\s*=\s*["']([^"']+\.js)["']/gi;
|
|
234
|
+
let m;
|
|
235
|
+
while ((m = re.exec(html))) {
|
|
236
|
+
try {
|
|
237
|
+
out.push(new URL(m[1], appUrl).href);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
/* a src that is not a URL is not a script we can read */
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return dedupe(out);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function firstMatch(text, re) {
|
|
246
|
+
const m = String(text).match(re);
|
|
247
|
+
return m ? m[0] : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// A Supabase anon key in the older JWT form: three base64url parts, the middle
|
|
251
|
+
// one decoding to a payload whose role is "anon". Checked, not assumed, so a
|
|
252
|
+
// service_role key that leaked into a bundle is never sent as if it were the
|
|
253
|
+
// public one.
|
|
254
|
+
function anonJwt(text) {
|
|
255
|
+
const re = /eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g;
|
|
256
|
+
let m;
|
|
257
|
+
while ((m = re.exec(text))) {
|
|
258
|
+
try {
|
|
259
|
+
const payload = JSON.parse(Buffer.from(m[0].split('.')[1], 'base64').toString('utf8'));
|
|
260
|
+
if (payload && payload.role === 'anon') return m[0];
|
|
261
|
+
} catch (err) {
|
|
262
|
+
/* not a JWT we can read */
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// The tables the app talks to, from the Supabase client calls the bundle is
|
|
269
|
+
// full of: .from("bookings"), .from('profiles').
|
|
270
|
+
function tableNames(text) {
|
|
271
|
+
const out = [];
|
|
272
|
+
const re = /\.from\(\s*["'`]([a-zA-Z_][a-zA-Z0-9_]*)["'`]\s*\)/g;
|
|
273
|
+
let m;
|
|
274
|
+
while ((m = re.exec(text))) out.push(m[1]);
|
|
275
|
+
return dedupe(out);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function dedupe(list) {
|
|
279
|
+
return Array.from(new Set((list || []).filter(Boolean)));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// PostgREST returns the total in a Content-Range header shaped "0-0/1234", or
|
|
283
|
+
// "*/0" when there is nothing. Null when the server did not send one.
|
|
284
|
+
function rangeCount(headers) {
|
|
285
|
+
const range = headers && typeof headers.get === 'function' ? headers.get('content-range') : null;
|
|
286
|
+
if (!range) return null;
|
|
287
|
+
const m = String(range).match(/\/(\d+)\s*$/);
|
|
288
|
+
return m ? Number(m[1]) : null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The report, as lines. Its own, not the deep scan's: that one says "I built a
|
|
293
|
+
* copy and attacked the copy", which is not what happened here, and a report
|
|
294
|
+
* that describes a different attack than the one that ran is lying about
|
|
295
|
+
* where its limits are.
|
|
296
|
+
*
|
|
297
|
+
* The part that matters most is at the end: what could not be tested from
|
|
298
|
+
* outside. A stranger's view settles one question - can anyone read this - and
|
|
299
|
+
* only for tables that have rows in them. Everything else needs a copy of the
|
|
300
|
+
* database, and a quiet result here must never be read as "the app is safe".
|
|
301
|
+
*/
|
|
302
|
+
function reportLines(found, result) {
|
|
303
|
+
const out = [];
|
|
304
|
+
const say = (text) => out.push(text === '' ? '' : ' ' + text);
|
|
305
|
+
const rule = () => say('-'.repeat(68));
|
|
306
|
+
const findings = finding.describeAll(result.findings || []);
|
|
307
|
+
const asked = (result.attempted || []).length;
|
|
308
|
+
|
|
309
|
+
say('');
|
|
310
|
+
say('App: ' + (found.appUrl || '(given directly)'));
|
|
311
|
+
say('Database: ' + found.supabaseUrl);
|
|
312
|
+
say('Tables I found in your app\'s code: ' + (asked ? (result.attempted || []).join(', ') : 'none'));
|
|
313
|
+
|
|
314
|
+
if (!asked) {
|
|
315
|
+
say('');
|
|
316
|
+
say('Your app\'s code does not name any table I could find, so there was');
|
|
317
|
+
say('nothing to ask about. This does NOT mean your data is safe - only that');
|
|
318
|
+
say('I could not see which tables to test from outside.');
|
|
319
|
+
} else if (findings.length) {
|
|
320
|
+
say('');
|
|
321
|
+
say(findings.length + (findings.length === 1 ? ' problem' : ' problems') + ' found.');
|
|
322
|
+
for (const item of findings) {
|
|
323
|
+
say('');
|
|
324
|
+
rule();
|
|
325
|
+
say(item.severity + ' ' + item.table);
|
|
326
|
+
say('');
|
|
327
|
+
say(item.headline);
|
|
328
|
+
say('');
|
|
329
|
+
for (const paragraph of [item.body, item.cause]) {
|
|
330
|
+
wrap(paragraph, 70).forEach((l) => say(l));
|
|
331
|
+
say('');
|
|
332
|
+
}
|
|
333
|
+
say('Paste this into Cursor, Claude, Windsurf or your AI tool:');
|
|
334
|
+
say('');
|
|
335
|
+
item.fixPrompt.split('\n').forEach((l) => out.push(' ' + l));
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
say('');
|
|
339
|
+
say('A stranger could not read rows from any table that had rows in it.');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const empty = result.emptyTables || [];
|
|
343
|
+
const shut = result.protectedTables || [];
|
|
344
|
+
const unclear = result.unclear || [];
|
|
345
|
+
|
|
346
|
+
say('');
|
|
347
|
+
rule();
|
|
348
|
+
say('How this was done');
|
|
349
|
+
say('');
|
|
350
|
+
wrap(
|
|
351
|
+
'Over the internet, as a stranger, with only the public key your app ' +
|
|
352
|
+
'already hands every visitor. I asked each table for a count and nothing ' +
|
|
353
|
+
'else, so no customer\'s row was pulled out, and nothing was written.',
|
|
354
|
+
70,
|
|
355
|
+
).forEach((l) => say(l));
|
|
356
|
+
|
|
357
|
+
say('');
|
|
358
|
+
rule();
|
|
359
|
+
say('What I could NOT test from outside');
|
|
360
|
+
say('');
|
|
361
|
+
if (empty.length) {
|
|
362
|
+
wrap(
|
|
363
|
+
'Could not tell: ' + empty.join(', ') + '. A stranger was allowed to ask ' +
|
|
364
|
+
'and got no rows back - which is what a well protected table looks like, ' +
|
|
365
|
+
'and also what an open but empty one looks like. From outside there is ' +
|
|
366
|
+
'no way to tell them apart.',
|
|
367
|
+
70,
|
|
368
|
+
).forEach((l) => say(l));
|
|
369
|
+
say('');
|
|
370
|
+
}
|
|
371
|
+
if (unclear.length) {
|
|
372
|
+
say('Got an answer I could not read: ' + unclear.map((u) => u.table).join(', ') + '.');
|
|
373
|
+
say('');
|
|
374
|
+
}
|
|
375
|
+
if (shut.length) {
|
|
376
|
+
say('Refused a stranger outright: ' + shut.join(', ') + '.');
|
|
377
|
+
say('');
|
|
378
|
+
}
|
|
379
|
+
wrap(
|
|
380
|
+
'Whether one signed-in customer can read another\'s rows, whether anyone ' +
|
|
381
|
+
'can add, change or delete rows, and whether the same thing can be saved ' +
|
|
382
|
+
'twice at once. Those change data, so they are only ever done on a copy of ' +
|
|
383
|
+
'your database. For that, and to settle the tables above:',
|
|
384
|
+
70,
|
|
385
|
+
).forEach((l) => say(l));
|
|
386
|
+
say('');
|
|
387
|
+
say(' npx kryptheon-night');
|
|
388
|
+
say('');
|
|
389
|
+
wrap(
|
|
390
|
+
'It asks for your database connection string (Supabase: Project Settings, ' +
|
|
391
|
+
'Database). Lovable Cloud does not hand that string out.',
|
|
392
|
+
70,
|
|
393
|
+
).forEach((l) => say(l));
|
|
394
|
+
say('');
|
|
395
|
+
return out;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
module.exports = {
|
|
399
|
+
reportLines: reportLines,
|
|
400
|
+
restBase: restBase,
|
|
401
|
+
discover: discover,
|
|
402
|
+
readable: readable,
|
|
403
|
+
prowl: prowl,
|
|
404
|
+
// exported for the checks
|
|
405
|
+
scriptUrls: scriptUrls,
|
|
406
|
+
tableNames: tableNames,
|
|
407
|
+
anonJwt: anonJwt,
|
|
408
|
+
rangeCount: rangeCount,
|
|
409
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kryptheon-night",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"schema.js",
|
|
14
14
|
"attack.js",
|
|
15
15
|
"finding.js",
|
|
16
|
+
"outside.js",
|
|
16
17
|
"collision.js",
|
|
17
18
|
"tamper.js",
|
|
18
19
|
"orphan.js",
|
|
@@ -29,12 +30,13 @@
|
|
|
29
30
|
"node": ">=18.0.0"
|
|
30
31
|
},
|
|
31
32
|
"scripts": {
|
|
32
|
-
"check": "node package.check.js && node trouble.check.js && node intro.check.js && node finding.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 loop.check.js",
|
|
33
|
-
"check:dry": "node package.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node recheck.check.js",
|
|
33
|
+
"check": "node package.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 loop.check.js",
|
|
34
|
+
"check:dry": "node package.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node recheck.check.js && node outside.check.js",
|
|
34
35
|
"check:package": "node package.check.js",
|
|
35
36
|
"check:trouble": "node trouble.check.js",
|
|
36
37
|
"check:intro": "node intro.check.js",
|
|
37
38
|
"check:report": "node finding.check.js",
|
|
39
|
+
"check:outside": "node outside.check.js",
|
|
38
40
|
"check:recheck": "node recheck.check.js",
|
|
39
41
|
"check:copy": "node schema.check.js",
|
|
40
42
|
"check:collision": "node collision.check.js",
|