kryptheon-night 0.1.6 → 0.1.7

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