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 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:
@@ -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
- if (arg === '--recheck') asked.recheck = true;
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
- const file = path.resolve(scanner.LAST_RUN);
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.6",
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
- // Where the last run is kept so the next one has something to compare against.
26
- // Beside the person's own project, not in a temp folder, because a re-check a
27
- // week later has to find it.
28
- const LAST_RUN = '.kryptheon-last.json';
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
- const file = path.resolve(LAST_RUN);
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
- LAST_RUN: LAST_RUN,
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
+ };