kryptheon-night 0.1.6 → 0.1.8
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 +75 -3
- package/finding.js +16 -0
- package/outside.js +409 -0
- package/package.json +8 -4
- package/scan.js +32 -7
- package/store.js +317 -0
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
|
@@ -26,12 +26,14 @@ const path = require('path');
|
|
|
26
26
|
const { Client } = require('pg');
|
|
27
27
|
|
|
28
28
|
const scanner = require('../scan.js');
|
|
29
|
+
const store = require('../store.js');
|
|
29
30
|
const recheck = require('../recheck.js');
|
|
30
31
|
const intro = require('../intro.js');
|
|
31
32
|
const trouble = require('../trouble.js');
|
|
32
33
|
const { howToConnect } = require('../connect.js');
|
|
33
34
|
const installer = require('../installer.js');
|
|
34
35
|
const finding = require('../finding.js');
|
|
36
|
+
const outside = require('../outside.js');
|
|
35
37
|
|
|
36
38
|
const line = (text) => process.stdout.write(text + '\n');
|
|
37
39
|
const fail = (text) => process.stderr.write(text + '\n');
|
|
@@ -45,6 +47,12 @@ function usage() {
|
|
|
45
47
|
'',
|
|
46
48
|
' npx kryptheon-night',
|
|
47
49
|
'',
|
|
50
|
+
' No connection string? Give it your running app instead. It reads the app',
|
|
51
|
+
' the way a stranger does and tells you which tables anyone can read -',
|
|
52
|
+
' counts only, nothing written:',
|
|
53
|
+
'',
|
|
54
|
+
' npx kryptheon-night https://your-app.example.com',
|
|
55
|
+
'',
|
|
48
56
|
' Run it again after you fix something and it tells you what is actually',
|
|
49
57
|
' closed - you do not have to ask it to. It also offers to do the same',
|
|
50
58
|
' check every night from inside your database, and says so if it already',
|
|
@@ -85,11 +93,15 @@ const VERBS = ['install', 'uninstall', 'status', 'night'];
|
|
|
85
93
|
/** What was asked for, and what was left to the default. */
|
|
86
94
|
function readArgs(argv) {
|
|
87
95
|
const asked = {
|
|
88
|
-
command: 'scan', schema: null, recheck: false, yes: false, help: false, unknown: [],
|
|
96
|
+
command: 'scan', schema: null, appUrl: null, recheck: false, yes: false, help: false, unknown: [],
|
|
89
97
|
};
|
|
90
98
|
for (let i = 0; i < argv.length; i++) {
|
|
91
99
|
const arg = argv[i];
|
|
92
|
-
|
|
100
|
+
// An address is the outside attack: the running app, read the way a
|
|
101
|
+
// stranger's browser reads it. Never a schema name - no schema is called
|
|
102
|
+
// https://anything.
|
|
103
|
+
if (/^https?:\/\//i.test(arg) && !asked.appUrl) asked.appUrl = arg;
|
|
104
|
+
else if (arg === '--recheck') asked.recheck = true;
|
|
93
105
|
else if (arg === '--yes' || arg === '-y') asked.yes = true;
|
|
94
106
|
else if (arg === '--help' || arg === '-h') asked.help = true;
|
|
95
107
|
else if (arg === '--schema') asked.schema = argv[++i] || null;
|
|
@@ -322,6 +334,45 @@ async function offerTheNight(client, target, canAsk) {
|
|
|
322
334
|
}
|
|
323
335
|
}
|
|
324
336
|
|
|
337
|
+
/**
|
|
338
|
+
* The attack from outside, end to end: find the backend in the app, ask each
|
|
339
|
+
* table as a stranger, say what came back and what could not be tested.
|
|
340
|
+
*
|
|
341
|
+
* Exit codes are the same three as the scan: 1 when a stranger read rows, 0
|
|
342
|
+
* when nothing came back, 2 when it could not run at all.
|
|
343
|
+
*/
|
|
344
|
+
async function runOutside(appUrl) {
|
|
345
|
+
line('');
|
|
346
|
+
line(' Kryptheon Night Shift - from outside');
|
|
347
|
+
line(' I read your app the way a stranger\'s browser does, and ask your');
|
|
348
|
+
line(' database what it lets a stranger see. Counts only, nothing written.');
|
|
349
|
+
line('');
|
|
350
|
+
line(' Reading ' + appUrl + ' ...');
|
|
351
|
+
|
|
352
|
+
let found;
|
|
353
|
+
try {
|
|
354
|
+
found = await outside.discover(appUrl);
|
|
355
|
+
} catch (err) {
|
|
356
|
+
fail('');
|
|
357
|
+
fail(' ' + (err.code ? err.message : 'I could not read ' + appUrl + ': ' + err.message));
|
|
358
|
+
fail('');
|
|
359
|
+
return 2;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let result;
|
|
363
|
+
try {
|
|
364
|
+
result = await outside.prowl(found);
|
|
365
|
+
} catch (err) {
|
|
366
|
+
fail('');
|
|
367
|
+
fail(' I found your database but could not ask it anything: ' + err.message);
|
|
368
|
+
fail('');
|
|
369
|
+
return 2;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
outside.reportLines(found, result).forEach(line);
|
|
373
|
+
return result.findings.length ? 1 : 0;
|
|
374
|
+
}
|
|
375
|
+
|
|
325
376
|
/** Prints a block of plain lines with the indent the rest of the report uses. */
|
|
326
377
|
function block(lines, write) {
|
|
327
378
|
(write || line)('');
|
|
@@ -343,6 +394,15 @@ async function main() {
|
|
|
343
394
|
process.exit(2);
|
|
344
395
|
}
|
|
345
396
|
|
|
397
|
+
// The outside attack. Needs no connection string, no copy and no yes: it
|
|
398
|
+
// only asks the live app what it already answers any visitor, and asks for
|
|
399
|
+
// counts, never rows. So nothing below - the string, the consent screen,
|
|
400
|
+
// the connection - applies to it.
|
|
401
|
+
if (asked.appUrl) {
|
|
402
|
+
process.exitCode = await runOutside(asked.appUrl);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
346
406
|
line('');
|
|
347
407
|
line(' Kryptheon Night Shift');
|
|
348
408
|
line(' I attack a copy of your database and tell you what got in.');
|
|
@@ -426,7 +486,19 @@ async function main() {
|
|
|
426
486
|
//
|
|
427
487
|
// The flag still works, because anything scripted may be passing it, and
|
|
428
488
|
// now it changes nothing: with an earlier run here, this always compares.
|
|
429
|
-
|
|
489
|
+
let file;
|
|
490
|
+
try {
|
|
491
|
+
file = scanner.lastRunFile(line);
|
|
492
|
+
} catch (err) {
|
|
493
|
+
// Nowhere to keep this run means nothing to compare the next one against,
|
|
494
|
+
// and a re-check with nothing to compare against is not an answer.
|
|
495
|
+
fail('');
|
|
496
|
+
fail(' Kryptheon keeps its last run for this project in ' + store.dirFor(process.cwd()));
|
|
497
|
+
fail(' and could not write there: ' + err.message);
|
|
498
|
+
fail(' Set KRYPTHEON_HOME to a folder you can write to, and run this again.');
|
|
499
|
+
fail('');
|
|
500
|
+
process.exit(2);
|
|
501
|
+
}
|
|
430
502
|
const before = scanner.loadLastRun(file);
|
|
431
503
|
if (asked.recheck && !before) {
|
|
432
504
|
// Asked for explicitly and impossible, which is worth saying. Arriving
|
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.8",
|
|
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",
|
|
@@ -23,18 +24,21 @@
|
|
|
23
24
|
"README.md",
|
|
24
25
|
"installer.js",
|
|
25
26
|
"sqlengine.js",
|
|
26
|
-
"engine.sql"
|
|
27
|
+
"engine.sql",
|
|
28
|
+
"store.js"
|
|
27
29
|
],
|
|
28
30
|
"engines": {
|
|
29
31
|
"node": ">=18.0.0"
|
|
30
32
|
},
|
|
31
33
|
"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",
|
|
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 loop.check.js",
|
|
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",
|
|
34
36
|
"check:package": "node package.check.js",
|
|
37
|
+
"check:store": "node store.check.js",
|
|
35
38
|
"check:trouble": "node trouble.check.js",
|
|
36
39
|
"check:intro": "node intro.check.js",
|
|
37
40
|
"check:report": "node finding.check.js",
|
|
41
|
+
"check:outside": "node outside.check.js",
|
|
38
42
|
"check:recheck": "node recheck.check.js",
|
|
39
43
|
"check:copy": "node schema.check.js",
|
|
40
44
|
"check:collision": "node collision.check.js",
|
package/scan.js
CHANGED
|
@@ -21,11 +21,26 @@ const collision = require('./collision.js');
|
|
|
21
21
|
const tamper = require('./tamper.js');
|
|
22
22
|
const orphan = require('./orphan.js');
|
|
23
23
|
const recheck = require('./recheck.js');
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
24
|
+
const store = require('./store.js');
|
|
25
|
+
|
|
26
|
+
// Where the last run is kept so the next one has something to compare against:
|
|
27
|
+
// the project's store under ~/.kryptheon, not a temp folder, because a
|
|
28
|
+
// re-check a week later has to find it - and not the project, because a saved
|
|
29
|
+
// run lists one app's tables and exactly where it is weak, which is the last
|
|
30
|
+
// thing that should ever be committed. It used to be .kryptheon-last.json in
|
|
31
|
+
// the project; the store moves one it finds there, once, and says so.
|
|
32
|
+
//
|
|
33
|
+
// Throws when the store cannot be made: a run that could not be saved would
|
|
34
|
+
// leave the next re-check nothing to hold anyone to.
|
|
35
|
+
function lastRunFile(say) {
|
|
36
|
+
const opened = store.open(process.cwd());
|
|
37
|
+
const lines = store.migrationLines(opened);
|
|
38
|
+
if (lines.length && say) {
|
|
39
|
+
say('');
|
|
40
|
+
lines.forEach(say);
|
|
41
|
+
}
|
|
42
|
+
return opened.nightLast;
|
|
43
|
+
}
|
|
29
44
|
|
|
30
45
|
const line = (text) => process.stdout.write(text + '\n');
|
|
31
46
|
|
|
@@ -530,7 +545,17 @@ async function main() {
|
|
|
530
545
|
process.exit(2);
|
|
531
546
|
}
|
|
532
547
|
|
|
533
|
-
|
|
548
|
+
let file;
|
|
549
|
+
try {
|
|
550
|
+
file = lastRunFile(line);
|
|
551
|
+
} catch (err) {
|
|
552
|
+
// 2, not 1: nothing was attacked, so nothing "got through".
|
|
553
|
+
console.error('');
|
|
554
|
+
console.error(' There is nowhere to keep this run (' + err.message + ').');
|
|
555
|
+
console.error(' Set KRYPTHEON_HOME to a folder you can write to, and run this again.');
|
|
556
|
+
console.error('');
|
|
557
|
+
process.exit(2);
|
|
558
|
+
}
|
|
534
559
|
const before = again ? loadLastRun(file) : null;
|
|
535
560
|
if (again && !before) {
|
|
536
561
|
// Running a fresh scan and calling it a re-check would report every
|
|
@@ -595,6 +620,6 @@ module.exports = {
|
|
|
595
620
|
saveRun: saveRun,
|
|
596
621
|
sweepOldCopies: sweepOldCopies,
|
|
597
622
|
notTestedLines: notTestedLines,
|
|
598
|
-
|
|
623
|
+
lastRunFile: lastRunFile,
|
|
599
624
|
ABANDONED_AFTER: ABANDONED_AFTER,
|
|
600
625
|
};
|
package/store.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// Where Kryptheon keeps what it remembers about a project.
|
|
2
|
+
//
|
|
3
|
+
// It used to be the project itself: kryptheon-baselines.json,
|
|
4
|
+
// kryptheon-history.jsonl, test-results/ and .kryptheon-last.json all appeared
|
|
5
|
+
// next to the person's own code. A plain landing page picked up six or seven
|
|
6
|
+
// files that way, and an AI assistant that commits "everything that changed"
|
|
7
|
+
// commits them too - the repo grows because Kryptheon is watching it.
|
|
8
|
+
//
|
|
9
|
+
// So every record now lives in one folder per project, outside it:
|
|
10
|
+
//
|
|
11
|
+
// ~/.kryptheon/projects/<folder-name>-<hash>/ (KRYPTHEON_HOME moves ~/.kryptheon)
|
|
12
|
+
// project.json which folder this store belongs to
|
|
13
|
+
// baselines.json was kryptheon-baselines.json
|
|
14
|
+
// history.jsonl was kryptheon-history.jsonl
|
|
15
|
+
// test-results/ was test-results/ (Playwright's screenshots of a failure)
|
|
16
|
+
// night-last.json was .kryptheon-last.json (kryptheon-night)
|
|
17
|
+
//
|
|
18
|
+
// Only tests/ - the recordings the person made - stays in the project.
|
|
19
|
+
//
|
|
20
|
+
// The hash is of the project's full path, so two folders that happen to share
|
|
21
|
+
// a name are never mixed. kryptheon-night carries a copy of this file and must
|
|
22
|
+
// land in the same folder for the same project; both packages check the ids
|
|
23
|
+
// below against the same fixed examples, so the two copies cannot drift apart.
|
|
24
|
+
//
|
|
25
|
+
// Records an older version left in a project are moved here the first time a
|
|
26
|
+
// project is opened, and only moved: nothing is thrown away, and when both
|
|
27
|
+
// places already hold a file the project's copy is kept in parked/ rather than
|
|
28
|
+
// overwriting what is here.
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const os = require('os');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
const crypto = require('crypto');
|
|
34
|
+
|
|
35
|
+
const HOME_ENV = 'KRYPTHEON_HOME';
|
|
36
|
+
|
|
37
|
+
const FILES = {
|
|
38
|
+
project: 'project.json',
|
|
39
|
+
baselines: 'baselines.json',
|
|
40
|
+
history: 'history.jsonl',
|
|
41
|
+
testResults: 'test-results',
|
|
42
|
+
nightLast: 'night-last.json',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// What older versions wrote into the project, and where each one goes now.
|
|
46
|
+
// append - history: lines the store does not have yet are added to it
|
|
47
|
+
// park - both exist: the project's copy goes to parked/, the store's stays
|
|
48
|
+
const LEGACY = [
|
|
49
|
+
{ from: 'kryptheon-baselines.json', to: FILES.baselines, onBoth: 'park' },
|
|
50
|
+
{ from: 'kryptheon-history.jsonl', to: FILES.history, onBoth: 'append' },
|
|
51
|
+
{ from: '.kryptheon-last.json', to: FILES.nightLast, onBoth: 'park' },
|
|
52
|
+
{ from: 'test-results', to: FILES.testResults, onBoth: 'park', dir: true },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// A project with a Playwright config of its own owns its test-results/ -
|
|
56
|
+
// Playwright's default output folder. Moving that would take the person's own
|
|
57
|
+
// screenshots away, so it is left exactly where it is.
|
|
58
|
+
const OWN_PLAYWRIGHT = /^playwright\.config\.(js|cjs|mjs|ts|cts|mts)$/;
|
|
59
|
+
|
|
60
|
+
/** ~/.kryptheon, or wherever KRYPTHEON_HOME points. */
|
|
61
|
+
function home(env) {
|
|
62
|
+
const source = env || process.env;
|
|
63
|
+
const set = source[HOME_ENV];
|
|
64
|
+
if (set && String(set).trim()) return path.resolve(String(set).trim());
|
|
65
|
+
return path.join(os.homedir(), '.kryptheon');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The project folder as one exact path, whatever form it was given in. */
|
|
69
|
+
function canonicalRoot(dir) {
|
|
70
|
+
const resolved = path.resolve(dir || process.cwd());
|
|
71
|
+
try {
|
|
72
|
+
return fs.realpathSync.native(resolved);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return resolved;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* <folder-name>-<12 hex>. Pure: the same path always gives the same id.
|
|
80
|
+
*
|
|
81
|
+
* Windows paths are compared without regard to case, because C:\Users\a and
|
|
82
|
+
* c:\users\a are the same folder there and must be the same project.
|
|
83
|
+
*/
|
|
84
|
+
function projectId(root, platform) {
|
|
85
|
+
const plat = platform || process.platform;
|
|
86
|
+
const P = plat === 'win32' ? path.win32 : path.posix;
|
|
87
|
+
// resolve() also drops a trailing separator and turns / into \ on Windows.
|
|
88
|
+
let key = P.resolve(String(root));
|
|
89
|
+
if (plat === 'win32') key = key.toLowerCase();
|
|
90
|
+
const hash = crypto.createHash('sha256').update(key, 'utf8').digest('hex').slice(0, 12);
|
|
91
|
+
const base = P.basename(key) || 'project';
|
|
92
|
+
const name = base.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+|-+$/g, '').slice(0, 40) || 'project';
|
|
93
|
+
return name + '-' + hash;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The store folder for a project. Only reads: nothing is created or moved, so
|
|
98
|
+
* the fixture and the reporter can ask for it just by being loaded.
|
|
99
|
+
*/
|
|
100
|
+
function dirFor(dir, env) {
|
|
101
|
+
const root = canonicalRoot(dir);
|
|
102
|
+
const short = path.join(home(env), 'projects', projectId(root));
|
|
103
|
+
const owner = readProject(path.join(short, FILES.project));
|
|
104
|
+
if (!owner || !owner.root || sameRoot(owner.root, root)) return short;
|
|
105
|
+
// Two different folders with the same short hash. Astronomically rare, but
|
|
106
|
+
// the one thing a store must never do is hold two projects, so the second
|
|
107
|
+
// one gets the full hash instead.
|
|
108
|
+
const full = crypto.createHash('sha256').update(root, 'utf8').digest('hex');
|
|
109
|
+
return short.replace(/-[0-9a-f]{12}$/, '-' + full);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Every file in a project's store, by name. Only reads, like dirFor. */
|
|
113
|
+
function pathsFor(dir, env) {
|
|
114
|
+
return pathsIn(dirFor(dir, env));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function pathsIn(dir) {
|
|
118
|
+
return {
|
|
119
|
+
dir: dir,
|
|
120
|
+
project: path.join(dir, FILES.project),
|
|
121
|
+
baselines: path.join(dir, FILES.baselines),
|
|
122
|
+
history: path.join(dir, FILES.history),
|
|
123
|
+
testResults: path.join(dir, FILES.testResults),
|
|
124
|
+
nightLast: path.join(dir, FILES.nightLast),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function exists(file) {
|
|
129
|
+
try {
|
|
130
|
+
fs.lstatSync(file);
|
|
131
|
+
return true;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function hasOwnPlaywright(root) {
|
|
138
|
+
try {
|
|
139
|
+
return fs.readdirSync(root).some((name) => OWN_PLAYWRIGHT.test(name));
|
|
140
|
+
} catch (err) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// rename is the whole move when both sides are on one drive. Across drives it
|
|
146
|
+
// fails with EXDEV, and then it is copy, check the copy, and only then remove.
|
|
147
|
+
function move(from, to, isDir) {
|
|
148
|
+
try {
|
|
149
|
+
fs.renameSync(from, to);
|
|
150
|
+
return;
|
|
151
|
+
} catch (err) {
|
|
152
|
+
// Anything else - a file held open by an editor, say - is reported and the
|
|
153
|
+
// record stays where it is, whole.
|
|
154
|
+
if (err.code !== 'EXDEV') throw err;
|
|
155
|
+
}
|
|
156
|
+
if (isDir) {
|
|
157
|
+
fs.cpSync(from, to, { recursive: true, errorOnExist: true, force: false });
|
|
158
|
+
fs.rmSync(from, { recursive: true, force: true });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const tmp = to + '.moving';
|
|
162
|
+
fs.copyFileSync(from, tmp);
|
|
163
|
+
if (fs.statSync(tmp).size !== fs.statSync(from).size) {
|
|
164
|
+
fs.rmSync(tmp, { force: true });
|
|
165
|
+
throw new Error('the copy of ' + from + ' came out a different size');
|
|
166
|
+
}
|
|
167
|
+
fs.renameSync(tmp, to);
|
|
168
|
+
fs.rmSync(from, { force: true });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Lines of the project's history the store does not have yet, in their order.
|
|
172
|
+
// Checked line by line so moving twice - say the project file could not be
|
|
173
|
+
// removed the first time - never writes a run down twice.
|
|
174
|
+
function appendHistory(from, to) {
|
|
175
|
+
const have = new Set(fs.readFileSync(to, 'utf8').split('\n').filter((l) => l.trim()));
|
|
176
|
+
const extra = fs.readFileSync(from, 'utf8').split('\n').filter((l) => l.trim() && !have.has(l));
|
|
177
|
+
if (extra.length) {
|
|
178
|
+
const current = fs.readFileSync(to, 'utf8');
|
|
179
|
+
const lead = current.length && !current.endsWith('\n') ? '\n' : '';
|
|
180
|
+
fs.appendFileSync(to, lead + extra.join('\n') + '\n', 'utf8');
|
|
181
|
+
}
|
|
182
|
+
fs.rmSync(from, { force: true });
|
|
183
|
+
return extra.length;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function stamp(now) {
|
|
187
|
+
return (now || new Date()).toISOString().replace(/[:.]/g, '-');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Moves what an older version left in the project into the store.
|
|
192
|
+
* Returns { moved: [names], parked: [{ name, to }], appended: [{ name, lines }],
|
|
193
|
+
* kept: [{ name, why }], failed: [{ name, why }] }.
|
|
194
|
+
*/
|
|
195
|
+
function migrate(root, dir, options) {
|
|
196
|
+
const out = { moved: [], parked: [], appended: [], kept: [], failed: [] };
|
|
197
|
+
const ownPlaywright = hasOwnPlaywright(root);
|
|
198
|
+
for (const item of LEGACY) {
|
|
199
|
+
const from = path.join(root, item.from);
|
|
200
|
+
if (!exists(from)) continue;
|
|
201
|
+
const label = item.from + (item.dir ? '/' : '');
|
|
202
|
+
if (item.dir && ownPlaywright) {
|
|
203
|
+
out.kept.push({ name: label, why: 'this project has its own Playwright config, so it is theirs' });
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const to = path.join(dir, item.to);
|
|
207
|
+
try {
|
|
208
|
+
if (!exists(to)) {
|
|
209
|
+
move(from, to, item.dir);
|
|
210
|
+
out.moved.push(label);
|
|
211
|
+
} else if (item.onBoth === 'append') {
|
|
212
|
+
out.appended.push({ name: label, lines: appendHistory(from, to) });
|
|
213
|
+
} else {
|
|
214
|
+
const parkedDir = path.join(dir, 'parked');
|
|
215
|
+
fs.mkdirSync(parkedDir, { recursive: true });
|
|
216
|
+
const parked = path.join(parkedDir, stamp(options && options.now) + '-' + item.from.replace(/^\./, ''));
|
|
217
|
+
move(from, parked, item.dir);
|
|
218
|
+
out.parked.push({ name: label, to: parked });
|
|
219
|
+
}
|
|
220
|
+
} catch (err) {
|
|
221
|
+
out.failed.push({ name: label, why: err.message });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The store for the project in `dir` (default: the current folder), created if
|
|
229
|
+
* it is not there yet, with anything an older version left in the project moved
|
|
230
|
+
* into it. Safe to call again: once moved, there is nothing left to move.
|
|
231
|
+
*
|
|
232
|
+
* Throws when the store cannot be created. A record that silently cannot be
|
|
233
|
+
* written makes every run look like the first one - which reads as "nothing to
|
|
234
|
+
* compare against, all fine" - so that is never swallowed here.
|
|
235
|
+
*/
|
|
236
|
+
function open(dir, options) {
|
|
237
|
+
const env = (options && options.env) || process.env;
|
|
238
|
+
const root = canonicalRoot(dir);
|
|
239
|
+
const storeDir = dirFor(root, env);
|
|
240
|
+
fs.mkdirSync(storeDir, { recursive: true });
|
|
241
|
+
const paths = pathsIn(storeDir);
|
|
242
|
+
if (!exists(paths.project)) {
|
|
243
|
+
fs.writeFileSync(paths.project, JSON.stringify({
|
|
244
|
+
root: root,
|
|
245
|
+
name: path.basename(root),
|
|
246
|
+
id: path.basename(storeDir),
|
|
247
|
+
createdAt: new Date().toISOString(),
|
|
248
|
+
}, null, 2) + '\n', 'utf8');
|
|
249
|
+
}
|
|
250
|
+
return Object.assign({ root: root, id: path.basename(storeDir) }, paths, {
|
|
251
|
+
migration: migrate(root, storeDir, options),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function sameRoot(a, b, platform) {
|
|
256
|
+
const plat = platform || process.platform;
|
|
257
|
+
const norm = (p) => {
|
|
258
|
+
const r = path.resolve(String(p)).replace(/[\\/]+$/, '');
|
|
259
|
+
return plat === 'win32' ? r.split('/').join('\\').toLowerCase() : r;
|
|
260
|
+
};
|
|
261
|
+
return norm(a) === norm(b);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function readProject(file) {
|
|
265
|
+
try {
|
|
266
|
+
let raw = fs.readFileSync(file, 'utf8');
|
|
267
|
+
if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
|
|
268
|
+
const parsed = JSON.parse(raw);
|
|
269
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
270
|
+
} catch (err) {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** What to tell the person about a migration, or nothing when nothing moved. */
|
|
276
|
+
function migrationLines(store) {
|
|
277
|
+
const m = store && store.migration;
|
|
278
|
+
if (!m) return [];
|
|
279
|
+
const lines = [];
|
|
280
|
+
const movedNames = m.moved.concat(m.appended.map((a) => a.name)).concat(m.parked.map((p) => p.name));
|
|
281
|
+
if (movedNames.length) {
|
|
282
|
+
lines.push(' Kryptheon now keeps its records outside your project, so they can never');
|
|
283
|
+
lines.push(' end up in your repo. Moved out of this folder:');
|
|
284
|
+
for (const name of movedNames) lines.push(' ' + name);
|
|
285
|
+
lines.push(' They are now in:');
|
|
286
|
+
lines.push(' ' + store.dir);
|
|
287
|
+
for (const p of m.parked) {
|
|
288
|
+
lines.push(' ' + p.name + ' was already there too, so this folder\'s copy is kept aside in:');
|
|
289
|
+
lines.push(' ' + p.to);
|
|
290
|
+
}
|
|
291
|
+
lines.push('');
|
|
292
|
+
}
|
|
293
|
+
for (const k of m.kept) {
|
|
294
|
+
lines.push(' Left ' + k.name + ' where it is: ' + k.why + '.');
|
|
295
|
+
lines.push('');
|
|
296
|
+
}
|
|
297
|
+
for (const f of m.failed) {
|
|
298
|
+
lines.push(' Could not move ' + f.name + ' out of this folder: ' + f.why);
|
|
299
|
+
lines.push(' It is still here. Close anything that has it open and run this again.');
|
|
300
|
+
lines.push('');
|
|
301
|
+
}
|
|
302
|
+
return lines;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
module.exports = {
|
|
306
|
+
HOME_ENV: HOME_ENV,
|
|
307
|
+
FILES: FILES,
|
|
308
|
+
LEGACY: LEGACY,
|
|
309
|
+
home: home,
|
|
310
|
+
canonicalRoot: canonicalRoot,
|
|
311
|
+
projectId: projectId,
|
|
312
|
+
dirFor: dirFor,
|
|
313
|
+
pathsFor: pathsFor,
|
|
314
|
+
open: open,
|
|
315
|
+
migrate: migrate,
|
|
316
|
+
migrationLines: migrationLines,
|
|
317
|
+
};
|