kryptheon-night 0.1.39 → 0.1.40

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.
Files changed (9) hide show
  1. package/attack.js +1028 -1025
  2. package/bin/kryptheon-night.js +736 -725
  3. package/engine.sql +4657 -4430
  4. package/finding.js +1715 -1612
  5. package/intro.js +311 -303
  6. package/package.json +98 -98
  7. package/scan.js +1330 -1311
  8. package/schema.js +1588 -1555
  9. package/tamper.js +949 -772
package/scan.js CHANGED
@@ -1,1311 +1,1330 @@
1
- // One night's work, end to end.
2
- //
3
- // read the app's shape -> rebuild it somewhere safe -> seed two fake
4
- // people -> attack the copy -> say what got through -> delete the copy
5
- //
6
- // The live app is read and never written to. Nothing is copied except the
7
- // shape: no rows, no customers, no orders. Every row the attack reads is a row
8
- // this tool put there itself, which is what makes the report safe to send.
9
- //
10
- // Run with:
11
- // KN_DATABASE_URL=postgresql://... node scan.js <schema>
12
- // KN_DATABASE_URL=postgresql://... node scan.js <schema> --recheck
13
-
14
- const fs = require('fs');
15
- const path = require('path');
16
- const { Client } = require('pg');
17
- const schema = require('./schema.js');
18
- const attack = require('./attack.js');
19
- const finding = require('./finding.js');
20
- const collision = require('./collision.js');
21
- const tamper = require('./tamper.js');
22
- const orphan = require('./orphan.js');
23
- const recheck = require('./recheck.js');
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
- }
44
-
45
- const line = (text) => process.stdout.write(text + '\n');
46
-
47
- // How long a copy has to be lying around before it is treated as abandoned.
48
- // Long enough that a scan running in another window is never swept out from
49
- // under itself; short enough that nobody finds week-old schemas in their
50
- // database.
51
- const ABANDONED_AFTER = 6 * 60 * 60 * 1000;
52
-
53
- /**
54
- * Drops copies an earlier run left behind.
55
- *
56
- * The copy's name carries the moment it was made, so its age can be read
57
- * without asking Postgres - which does not record when a schema was created.
58
- * Anything younger than a few hours is left strictly alone: it may well belong
59
- * to a scan that is running right now.
60
- */
61
- async function sweepOldCopies(client, mine) {
62
- const dropped = [];
63
- let rows = [];
64
- try {
65
- ({ rows } = await client.query(
66
- // Copies, and the engine schemas the SQL door installs. A copy is
67
- // kn_<moment>; an engine is kn_engine_<moment>. The engine used to be
68
- // outside this pattern entirely, so one abandoned by a dropped
69
- // connection sat in the customer's database for ever - which is the
70
- // one thing this product promises never to do.
71
- "SELECT nspname FROM pg_namespace WHERE nspname ~ '^kn_(engine_)?[0-9a-z]+$' AND nspname <> $1",
72
- [mine],
73
- ));
74
- } catch (err) {
75
- return dropped;
76
- }
77
-
78
- for (const row of rows) {
79
- // The moment is whatever follows the last underscore, which is the
80
- // whole name after kn_ for a copy and the tail for an engine.
81
- const parts = String(row.nspname).split('_');
82
- const made = parseInt(parts[parts.length - 1], 36);
83
- if (!Number.isFinite(made) || Date.now() - made < ABANDONED_AFTER) continue;
84
- try {
85
- await client.query('DROP SCHEMA IF EXISTS ' + schema.quote(row.nspname) + ' CASCADE');
86
- dropped.push(row.nspname);
87
- } catch (err) {
88
- // Not ours to force. Better to leave it than to fail somebody's scan.
89
- }
90
- }
91
- return dropped;
92
- }
93
-
94
- /**
95
- * Every schema in the database, and how many tables are in each.
96
- *
97
- * Nothing filtered out. The first version left out anything named like one of
98
- * our copies, which made the scan refuse to look at a schema whose name merely
99
- * began the same way - and that took the entire shapes suite down a minute
100
- * after this function was written. What to hide is a question for the
101
- * suggestion below, not for whether a schema exists.
102
- */
103
- async function schemasWithTables(client) {
104
- const { rows } = await client.query(
105
- `SELECT n.nspname AS schema, count(c.oid) FILTER (WHERE c.relkind = 'r')::int AS tables
106
- FROM pg_namespace n
107
- LEFT JOIN pg_class c ON c.relnamespace = n.oid
108
- WHERE n.nspname NOT LIKE 'pg\\_%'
109
- AND n.nspname <> 'information_schema'
110
- GROUP BY 1
111
- ORDER BY 2 DESC, 1`,
112
- );
113
- return rows;
114
- }
115
-
116
- /**
117
- * What to try instead.
118
- *
119
- * A person who mistyped a schema name is about to mistype it again. Listing
120
- * what is actually there costs one query and saves the second attempt - but
121
- * not the throwaway copies, which are ours and about to be deleted.
122
- */
123
- function suggest(schemas) {
124
- const worth = schemas.filter((entry) => entry.tables > 0 && !/^kn_[0-9a-z]+$/.test(entry.schema));
125
- if (!worth.length) return '\n This database has no schema with tables in it.';
126
- return '\n Schemas in this database with tables in them: ' +
127
- worth.map((entry) => entry.schema + ' (' + entry.tables + ')').join(', ');
128
- }
129
-
130
- /**
131
- * The engine, as seven operations, so the scan does not know which one it has.
132
- *
133
- * `scan.js` named `schema.js`, `attack.js`, `tamper.js` and `orphan.js`
134
- * directly, which meant the SQL engine could only ever be reached by the twin
135
- * check. Two engines and one of them never on the real path is how the unused
136
- * one rots however good the comparison is.
137
- *
138
- * Deliberately not included: the collision race. It needs two requests in
139
- * flight at once, which needs two connections, and no credential ever moves
140
- * into the database - so the SQL engine cannot have it and says so. The scan
141
- * keeps racing for real down `openSession` whichever engine it is given, and
142
- * the report says what could not be raced when there is no second connection.
143
- * That is the one place the two doors are honestly not the same, and it is
144
- * named rather than papered over.
145
- */
146
- function nodeEngine() {
147
- return {
148
- name: 'node',
149
- readSchema: (client, target) => schema.readSchema(client, target),
150
- writeSchema: (client, plan, into) => schema.writeSchema(client, plan, into),
151
- seed: (client, into, tables) => attack.seed(client, into, tables),
152
- impersonate: (client, into, tables) => attack.impersonate(client, into, tables),
153
- tamper: (client, into, tables, seeded, policies) =>
154
- tamper.tamper(client, into, tables, seeded, policies),
155
- orphan: (client, into, tables, seeded) => orphan.orphan(client, into, tables, seeded),
156
- teammate: (client, into, tables, seeded, rules) => tamper.teammate(client, into, tables, seeded, rules),
157
- conflicts: (client, into, plan, indexes) => conflictsIn(client, into, plan, indexes),
158
- };
159
- }
160
-
161
- /**
162
- * Runs the whole thing and hands back what got through.
163
- *
164
- * The copy is dropped in a `finally`, so a crash halfway does not leave a
165
- * database behind with somebody's schema in it.
166
- */
167
- async function scan(client, sourceSchema, options) {
168
- const opts = options || {};
169
- const copyName = 'kn_' + Date.now().toString(36);
170
- const say = opts.quiet ? () => {} : line;
171
- // Whichever engine the caller handed over, or the one built in.
172
- const engine = opts.engine || nodeEngine();
173
-
174
- // Anything an earlier run could not clean up after itself. The copy is
175
- // dropped in a `finally`, but a `finally` needs a connection: when the link
176
- // dies mid-scan the process goes with it and the copy is simply left in the
177
- // customer's database. Found by exactly that happening on a real schema.
178
- const swept = await sweepOldCopies(client, copyName);
179
- if (swept.length) say(' Cleared ' + swept.length + ' copy left by an earlier run.');
180
-
181
- // Is there anything here at all?
182
- //
183
- // Typed `pubic` instead of `public`, this used to read a schema that does
184
- // not exist, find no tables, attack none of them, and print "Nothing got
185
- // through. Your data held." A typo produced the one sentence the entire
186
- // product is sold on, and exited 0 so a script would call it a pass.
187
- const elsewhere = await schemasWithTables(client);
188
- const here = elsewhere.find((entry) => entry.schema === sourceSchema);
189
- if (!here) {
190
- return {
191
- stopped: 'There is no schema called "' + sourceSchema + '" in this database.' +
192
- suggest(elsewhere),
193
- findings: [],
194
- };
195
- }
196
-
197
- say(' Reading the shape of ' + sourceSchema + ' ...');
198
- const plan = await engine.readSchema(client, sourceSchema);
199
-
200
- if (!plan.tables.length) {
201
- // Nothing wrong with the database, but nothing was tested either, and
202
- // those are not the same answer.
203
- return {
204
- stopped: 'The schema "' + sourceSchema + '" has no tables in it, so there was nothing to attack.' +
205
- suggest(elsewhere.filter((entry) => entry.schema !== sourceSchema)),
206
- findings: [],
207
- };
208
- }
209
-
210
- if (plan.unsupported.length) {
211
- // Attacking a copy that is missing pieces would produce verdicts about a
212
- // database nobody runs, so this stops rather than guesses.
213
- return {
214
- stopped: 'Parts of this app could not be copied faithfully:\n ' + plan.unsupported.join('\n '),
215
- findings: [],
216
- };
217
- }
218
-
219
- say(' ' + plan.tables.length + ' tables, ' + plan.policies.length + ' rules. Building a copy ...');
220
-
221
- try {
222
- await engine.writeSchema(client, plan, copyName);
223
- const copyPlan = await engine.readSchema(client, copyName);
224
-
225
- // The copy has to be the app, or nothing that follows means anything.
226
- const differences = schema.diffSchemas(plan, copyPlan);
227
- if (differences.length) {
228
- return {
229
- stopped: 'The copy did not come out identical, so nothing was attacked:\n ' + differences.join('\n '),
230
- findings: [],
231
- };
232
- }
233
-
234
- // Upserts Postgres would refuse, asked of the empty copy before anything
235
- // is put in it - planned, never run (conflictsIn).
236
- const refused = engine.conflicts ? await engine.conflicts(client, copyName, plan, copyPlan.indexes) : [];
237
-
238
- say(' Copy matches. Seeding two people and attacking it ...');
239
- // The stand-ins built for tables outside the schema belong to this tool,
240
- // not to the customer. Attacking them would produce findings about a table
241
- // that does not exist in their app.
242
- // Named one by one from what was actually built, not matched on a prefix.
243
- // A customer table whose name merely looked like ours used to be dropped
244
- // from every attack, and a table nobody attacks has nothing to report.
245
- const standIns = new Set((plan.external || []).map((entry) => entry.stub));
246
- const theirs = copyPlan.tables.filter((table) => !standIns.has(table.name));
247
- const sown = await engine.seed(client, copyName, theirs);
248
-
249
- // Views are read but never seeded: they have no rows of their own, they
250
- // show the rows of the tables underneath. That is exactly why they matter
251
- // - a view runs with its creator's rights unless it says otherwise, so one
252
- // over a protected table hands out every row in it while the policy sits
253
- // there intact and the dashboard stays green.
254
- const views = (copyPlan.views || []).map((view) => ({
255
- name: view.name,
256
- columns: view.columns || [],
257
- constraints: [],
258
- rlsEnabled: false,
259
- isView: true,
260
- }));
261
- const impersonation = await engine.impersonate(client, copyName, theirs.concat(views));
262
-
263
- // Every attack genuinely run, named the way a finding is named. The
264
- // re-check needs this: a finding that disappears because its attack never
265
- // ran this time is not a finding that was fixed, and without this list the
266
- // two are indistinguishable.
267
- //
268
- // Two conditions, both required. The read has to have produced a verdict,
269
- // AND the table has to have had a row in it - because a table nothing
270
- // could be seeded into returns nothing to a stranger for a reason that has
271
- // nothing to do with being secure.
272
- const sownTables = new Set(sown.seeded.map((entry) => entry.table));
273
- // A view counts as tested once anything underneath it has rows, since that
274
- // is what it has to show.
275
- const viewNames = new Set(views.map((view) => view.name));
276
- const attempted = impersonation.completed.filter((key) => {
277
- const name = key.split(':')[1];
278
- // Whether a rule loops needs no rows: Postgres finds the loop while
279
- // planning the read, before it looks at a single row. A table nothing
280
- // could be seeded into has still been checked for that.
281
- if (key.startsWith('recursive:')) return true;
282
- return sownTables.has(name) || (viewNames.has(name) && sown.seeded.length > 0);
283
- });
284
-
285
- // Surfaced, not swallowed. A table with no row in it reads as a safe
286
- // table, so the one thing that must never happen is reporting an app as
287
- // clear when part of it was never actually tried.
288
- const notChecked = sown.skipped.slice().concat(serverIdentityOf(plan));
289
-
290
- // A read that errored for any reason other than the table being closed to
291
- // that role decided nothing at all, and zero rows back from a failed query
292
- // looks exactly like zero rows back from a table that held.
293
- for (const stuck of impersonation.blocked) {
294
- if (!sownTables.has(stuck.table) && !viewNames.has(stuck.table)) continue;
295
- notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
296
- }
297
-
298
- // Can a stranger change any of it? Every write here is rolled back, so the
299
- // copy comes out of this exactly as it went in and anything running after
300
- // reads the same database the earlier attacks did.
301
- say(' Trying to change data that is not ours ...');
302
- // The copy's own policies, not the app's - they are the same rules, and
303
- // the copy is what was actually attacked. The report needs them to say
304
- // which rule let a write through rather than assuming one.
305
- const writes = await engine.tamper(client, copyName, theirs, sown.seeded, copyPlan.policies);
306
- for (const key of writes.completed) attempted.push(key);
307
- for (const stuck of writes.blocked) {
308
- notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
309
- }
310
-
311
- // What the least trusted member of a team can change there. Rolled back
312
- // like the writes above, and the joining with them.
313
- // With the copy's rules and helpers, so a role a rule lists as allowed to
314
- // write is not reported (tamper.allowedByRules).
315
- const ranked = await engine.teammate(client, copyName, theirs, sown.seeded,
316
- { policies: copyPlan.policies, functions: copyPlan.functions });
317
- for (const key of ranked.completed) attempted.push(key);
318
- for (const stuck of ranked.blocked) {
319
- notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
320
- }
321
-
322
- // Can a half-finished write survive? Rolled back like the writes above.
323
- say(' Looking for rows that could point at nothing ...');
324
- const stranded = await engine.orphan(client, copyName, theirs, sown.seeded);
325
- for (const key of stranded.completed) attempted.push(key);
326
- for (const missed of stranded.notTried) {
327
- notChecked.push({
328
- table: missed.table + '.' + missed.column,
329
- key: 'orphaned:' + missed.table + ':' + missed.column,
330
- why: missed.why,
331
- });
332
- }
333
-
334
- // A rule that looks itself up. The attacks it stopped stay in the list of
335
- // what was not tested - they did not run - but the loop is also a finding
336
- // of its own: every signed-in request that touches the table fails, so the
337
- // app is broken for the people using it. One finding per table at fault,
338
- // named from Postgres's own message, however many reads ran into it.
339
- const loops = new Map();
340
- // A loop that named no table ("stack depth limit exceeded") is put on the
341
- // tables whose own rule calls a helper that reads that same table; if
342
- // none can be found, on the table that was being read.
343
- const culprits = loopingTables(copyPlan);
344
- const loopedInto = (relation, table, who) => {
345
- const deep = !relation;
346
- for (const at of relation ? [relation] : culprits.length ? culprits : [table]) {
347
- if (!loops.has(at)) loops.set(at, { kind: 'recursive', table: at, reads: [], callers: [], columns: [], deep: false });
348
- const entry = loops.get(at);
349
- if (deep) entry.deep = true;
350
- if (!entry.reads.includes(table)) entry.reads.push(table);
351
- if (!entry.callers.includes(who)) entry.callers.push(who);
352
- }
353
- };
354
- for (const hit of impersonation.looped || []) loopedInto(hit.relation, hit.table, hit.who);
355
- for (const stuck of writes.blocked) {
356
- const relation = attack.recursionIn(stuck.why);
357
- if (relation || attack.tooDeep(stuck.why)) loopedInto(relation || null, stuck.table, /^as anyone:/.test(stuck.why) ? 'anyone' : 'signed-in');
358
- }
359
- const recursive = Array.from(loops.values());
360
-
361
- let collisions = { findings: [], notTried: [], raced: [] };
362
- if (opts.openSession) {
363
- say(' Racing two requests against each other ...');
364
- const one = await opts.openSession();
365
- const two = await opts.openSession();
366
- try {
367
- collisions = await collision.collide(client, one, two, copyName, theirs, copyPlan.indexes);
368
- } finally {
369
- await one.end().catch(() => {});
370
- await two.end().catch(() => {});
371
- }
372
- for (const raced of collisions.raced) {
373
- attempted.push('duplicated:' + raced.table + ':' + raced.column);
374
- }
375
- for (const missed of collisions.notTried) {
376
- notChecked.push({
377
- table: missed.table + '.' + missed.column,
378
- key: 'duplicated:' + missed.table + ':' + missed.column,
379
- why: missed.why,
380
- });
381
- }
382
- } else {
383
- // Two requests at once needs two connections. Without them the attack
384
- // cannot happen at all, and a report that quietly omits it reads exactly
385
- // like a report that ran it and found nothing.
386
- for (const target of collision.candidates(theirs, copyPlan.indexes).filter((t) => !t.covered)) {
387
- notChecked.push({
388
- table: target.table + '.' + target.column,
389
- key: 'duplicated:' + target.table + ':' + target.column,
390
- why: 'racing two requests needs a second connection, and none was available',
391
- });
392
- }
393
- }
394
-
395
- // Where people actually sign in decides how bad a duplicate email is. On
396
- // Supabase that is auth.users, which keeps emails unique; a public table's
397
- // email column is then a copy. Only read, never written.
398
- const loginElsewhere = (await client.query("select to_regclass('auth.users') is not null as found")).rows[0].found;
399
- const raced = collisions.findings.map((f) =>
400
- f.expectation === 'identity' ? Object.assign({}, f, { loginElsewhere: loginElsewhere }) : f,
401
- );
402
-
403
- // Functions an anonymous visitor can call that run past the rules. Read
404
- // from the shape, not attacked: nothing is executed, because whether such
405
- // a function is meant to be public is a question only the owner answers.
406
- //
407
- // Every definer function read this run counts as examined, whether or not
408
- // anon can call it. That is what lets the re-check call a revoked grant
409
- // fixed: the function was looked at again and the reach was gone. The first
410
- // version recorded only the flagged ones, so a revoke made the function
411
- // vanish from both lists and the fix came back as "could not confirm".
412
- for (const fn of plan.anonFunctions || []) attempted.push('privileged:' + fn.name);
413
- const privileged = privilegedOf(plan);
414
-
415
- // How the app's functions and columns are declared: an open search_path,
416
- // a list a CHECK keeps that still takes an empty value. Read from the
417
- // shape. Every function and column looked at counts as examined, so a fix
418
- // comes back from a re-check as fixed.
419
- for (const fn of plan.anonFunctions || []) attempted.push('searchpath:' + fn.name);
420
- for (const fn of plan.functions || []) attempted.push('conflict:' + fn.name);
421
- for (const x of oneOfColumns(plan)) attempted.push('nullcheck:' + x.table + ':' + x.column.name);
422
- // Only a table that has a rule for adding rows was looked at for this.
423
- for (const name of new Set((plan.policies || []).filter((p) => /^(INSERT|ALL)$/i.test(String(p.cmd || 'ALL'))).map((p) => p.table_name))) {
424
- attempted.push('noinsert:' + name);
425
- }
426
- const declared = searchPathsOf(plan).concat(refused, emptyChoicesOf(plan), noInsertsOf(plan));
427
-
428
- // Storage buckets switched to public that also have a rule saying who may
429
- // read them. Read from the bucket settings and the rules, never a file.
430
- // Every bucket read counts as examined, so one made private comes back
431
- // from a re-check as fixed.
432
- for (const bucket of plan.buckets || []) attempted.push('bucket:' + bucket.name);
433
- const buckets = bucketsOf(plan);
434
-
435
- // Ties read from the original schema: the copy points auth.users keys at
436
- // a stand-in, and would call every table untied. Owners read from the
437
- // copy, the way seeding did.
438
- const tagged = taggerFor(plan, theirs);
439
-
440
- // Where this run was, so a re-check can tell a database it saw before from
441
- // another one entirely (recheck.elsewhereOf). The name and address only.
442
- const at = (await client.query(
443
- 'SELECT current_database() AS database, host(inet_server_addr()) AS host, inet_server_port() AS port',
444
- )).rows[0];
445
-
446
- const everything = impersonation.findings.concat(writes.findings, ranked.findings, stranded.findings, raced,
447
- privileged, declared, buckets, recursive);
448
-
449
- return {
450
- stopped: null,
451
- // The app's functions read and never run - named under "What I did not test".
452
- notCalled: notCalledOf(plan, everything),
453
- where: {
454
- database: at.database,
455
- host: at.host || null,
456
- port: at.port || null,
457
- schema: sourceSchema,
458
- tables: plan.tables.map((table) => table.name).sort(),
459
- },
460
- attacksRun: attempted.length,
461
- notChecked: notChecked,
462
- attempted: attempted,
463
- findings: finding.describeAll(everything.map(tagged)),
464
- };
465
- } finally {
466
- try {
467
- await client.query('DROP SCHEMA IF EXISTS ' + schema.quote(copyName) + ' CASCADE');
468
- say(' Copy deleted.');
469
- } catch (err) {
470
- line(' WARNING: the copy ' + copyName + ' could not be deleted: ' + err.message);
471
- }
472
- }
473
- }
474
-
475
- /**
476
- * The exit code of a first run: 2 could not run, 1 something got through, 0
477
- * every attack lost. Only confirmed findings count as "got through" - a
478
- * function flagged for verification was never executed, and a script reading
479
- * 1 would act on a break nobody demonstrated.
480
- */
481
- function exitCodeFor(result) {
482
- if (result.stopped) return 2;
483
- return (result.findings || []).some((f) => f.status !== 'verification required') ? 1 : 0;
484
- }
485
-
486
- /**
487
- * The tables whose own rule reaches back into the same table through the
488
- * helpers it calls - directly or through helpers those call. Such a rule has
489
- * to be evaluated to be evaluated, which is how "stack depth limit exceeded"
490
- * happens; the message itself does not say which table.
491
- */
492
- function loopingTables(plan) {
493
- const escape = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
494
- const calls = (text, name) => new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?\\s*\\(').test(String(text || ''));
495
- const mentions = (text, name) => new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?([^A-Za-z0-9_$]|$)').test(String(text || ''));
496
- const fns = (plan && plan.functions) || [];
497
- const reached = (text) => {
498
- const found = new Set(fns.filter((f) => calls(text, f.name)).map((f) => f.name));
499
- let grew = true;
500
- while (grew) {
501
- grew = false;
502
- for (const f of fns) {
503
- if (found.has(f.name)) continue;
504
- if (fns.some((g) => found.has(g.name) && calls(g.src, f.name))) { found.add(f.name); grew = true; }
505
- }
506
- }
507
- return fns.filter((f) => found.has(f.name));
508
- };
509
- return ((plan && plan.tables) || []).map((t) => t.name).filter((name) => {
510
- const rules = ((plan && plan.policies) || []).filter((p) => p.table_name === name)
511
- .map((p) => String(p.qual || '') + ' ' + String(p.with_check || '')).join(' ');
512
- return rules.trim() && reached(rules).some((f) => mentions(f.src, name));
513
- });
514
- }
515
-
516
- /**
517
- * Functions an anonymous visitor can call that run past the rules. Read from
518
- * the shape, not attacked. Quiet: a yes/no about the caller, and a function
519
- * that turns the caller away before it does anything (schema.guardedAtTheDoor).
520
- */
521
- function privilegedOf(plan) {
522
- return ((plan && plan.anonFunctions) || []).filter((fn) => fn.callable && !fn.aboutCaller && !fn.guarded).map((fn) => ({
523
- kind: 'privileged',
524
- fn: fn.name,
525
- table: fn.name,
526
- args: fn.args,
527
- writes: fn.writes,
528
- hasFixedSearchPath: fn.hasFixedSearchPath,
529
- // Its only check at the door is one a caller with no role slips past.
530
- nullSlips: fn.nullSlips === true,
531
- columns: [],
532
- }));
533
- }
534
-
535
- /**
536
- * Definer functions the app can call that leave their search_path open.
537
- *
538
- * Found on a blind test (Northstar): record_scan was SECURITY DEFINER,
539
- * callable by every signed-in user, guarded at the door - and it said
540
- * `insert into scans` with no schema and no search_path set. A name without a
541
- * schema is looked up along whatever path the session has, so anybody able to
542
- * put a table or function earlier on that path makes the function use theirs,
543
- * with the owner's rights. Supabase's own advisor flags the same thing.
544
- *
545
- * Read, never run. A function already reported as privileged says this as
546
- * part of that finding and is not reported twice.
547
- */
548
- function searchPathsOf(plan) {
549
- const reported = new Set(privilegedOf(plan).map((f) => f.fn));
550
- return ((plan && plan.anonFunctions) || [])
551
- .filter((fn) => (fn.callable || fn.callableSignedIn) && !fn.hasFixedSearchPath && !reported.has(fn.name))
552
- .map((fn) => ({
553
- kind: 'searchpath',
554
- fn: fn.name,
555
- table: fn.name,
556
- args: fn.args,
557
- writes: fn.writes,
558
- anon: fn.callable === true,
559
- columns: [],
560
- }));
561
- }
562
-
563
- // A CHECK that keeps one column to a fixed list, as Postgres writes it back:
564
- // CHECK ((severity = ANY (ARRAY['critical'::text, 'high'::text])))
565
- // CHECK (((status)::text = ANY ((ARRAY['a'::character varying])::text[])))
566
- // Mirrored in engine.sql empty_choices.
567
- const ONE_OF = /^CHECK \(+"?([A-Za-z_][\w$]*)"?\)?(?:::[\w ]+)? = ANY \(/;
568
-
569
- /**
570
- * Columns kept to a fixed list by a CHECK that still take an empty value.
571
- *
572
- * Found on a blind test (Northstar): findings.severity had
573
- * CHECK (severity IN ('critical', 'high', 'medium', 'low')) and no NOT NULL.
574
- * A CHECK refuses a row only when it can show the value wrong, and NULL is
575
- * never shown wrong - so a finding with no severity at all was accepted, and
576
- * the app's report showed a row with no label. Only said when nothing else
577
- * fills the column: no default, not generated, and a CHECK that does not
578
- * mention NULL itself (a CHECK that does has decided the question).
579
- */
580
- /**
581
- * Tables nobody can add a row to: every rule for adding rows is an INSERT
582
- * rule written with neither USING nor WITH CHECK, which Postgres reads as
583
- * "no" - measured on PostgreSQL 18, a bare `create policy p on t for insert
584
- * to authenticated;` refuses every insert. A rule for ALL, or one with a
585
- * check, is a way in, and the table is left alone. Mirrors no_insert_findings
586
- * in engine.sql.
587
- *
588
- * Found on blind round 15 (Northstar Ops on Supabase): tickets_insert was
589
- * written that way, so create_ticket failed for every user - the app's main
590
- * feature - and nothing in the report said so.
591
- */
592
- function noInsertsOf(plan) {
593
- const byTable = new Map();
594
- for (const p of (plan && plan.policies) || []) {
595
- if (String(p.permissive || 'PERMISSIVE').toUpperCase() !== 'PERMISSIVE') continue;
596
- const cmd = String(p.cmd || 'ALL').toUpperCase();
597
- if (cmd !== 'INSERT' && cmd !== 'ALL') continue;
598
- if (!byTable.has(p.table_name)) byTable.set(p.table_name, { bare: [], open: false });
599
- const entry = byTable.get(p.table_name);
600
- if (cmd === 'INSERT' && p.qual == null && p.with_check == null) entry.bare.push(p.name);
601
- else entry.open = true;
602
- }
603
- return Array.from(byTable.keys()).sort()
604
- .filter((table) => byTable.get(table).bare.length && !byTable.get(table).open)
605
- .map((table) => ({ kind: 'noinsert', table: table, rules: byTable.get(table).bare.slice().sort(), columns: [] }));
606
- }
607
-
608
- function emptyChoicesOf(plan) {
609
- return oneOfColumns(plan)
610
- .filter((x) => !x.column.not_null && x.column.default_expr == null && !x.column.generated)
611
- .map((x) => ({
612
- kind: 'nullcheck',
613
- table: x.table,
614
- column: x.column.name,
615
- allowed: Array.from(x.definition.matchAll(/'((?:[^']|'')*)'/g)).map((m) => m[1].split("''").join("'")),
616
- constraint: x.constraint,
617
- columns: [],
618
- }));
619
- }
620
-
621
- /** Every column a CHECK keeps to a fixed list - each one looked at, empty or not. */
622
- function oneOfColumns(plan) {
623
- const found = [];
624
- for (const table of (plan && plan.tables) || []) {
625
- for (const rule of table.constraints || []) {
626
- if (rule.kind !== 'c') continue;
627
- const def = String(rule.definition || '');
628
- if (/\b(or|null|and)\b/i.test(def)) continue;
629
- const m = ONE_OF.exec(def);
630
- if (!m) continue;
631
- const column = (table.columns || []).find((c) => c.name === m[1]);
632
- if (column) found.push({ table: table.name, column: column, constraint: rule.name, definition: def });
633
- }
634
- }
635
- return found;
636
- }
637
-
638
- /**
639
- * Every INSERT ... ON CONFLICT (...) in the app's functions, with the table it
640
- * writes and the conflict target, read from the function's text. Mirrored in
641
- * engine.sql conflict_targets, step for step: statements split on ";", the
642
- * table after INSERT INTO, the columns inside ON CONFLICT ( ), and a WHERE
643
- * right after them up to DO.
644
- */
645
- function conflictTargetsOf(plan) {
646
- const tables = new Set(((plan && plan.tables) || []).map((t) => t.name));
647
- const unquote = (part) => (part.startsWith('"') ? part.slice(1, -1) : part.toLowerCase());
648
- const seen = new Set();
649
- const out = [];
650
- for (const fn of (plan && plan.functions) || []) {
651
- const body = String(fn.src || '').replace(/--[^\n]*/g, ' ');
652
- for (const statement of body.split(';')) {
653
- const into = /(?:^|[^\w$])insert\s+into\s+((?:"[^"]+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"[^"]+"|[A-Za-z_][\w$]*))?)/i.exec(statement);
654
- if (!into) continue;
655
- const on = /(?:^|[^\w$])on\s+conflict\s*\(([^)]*)\)([\s\S]*)$/i.exec(statement);
656
- if (!on || !on[1].trim()) continue;
657
- const parts = into[1].match(/"[^"]+"|[A-Za-z_][\w$]*/g);
658
- if (parts.length === 2 && unquote(parts[0]) !== plan.schema) continue;
659
- const target = unquote(parts[parts.length - 1]);
660
- if (!tables.has(target)) continue;
661
- const where = /^\s*where\b/i.test(on[2]) ? on[2].split(/\bdo\b/i)[0].trim() : '';
662
- const key = [fn.name, target, on[1].trim(), where].join('|');
663
- if (seen.has(key)) continue;
664
- seen.add(key);
665
- out.push({ fn: fn.name, args: fn.args || '', target: target, on: on[1].trim(), where: where });
666
- }
667
- }
668
- return out;
669
- }
670
-
671
- /**
672
- * The WHERE of a partial unique index on exactly these columns, if there is
673
- * one: what the ON CONFLICT has to repeat to be matched. Mirrored in
674
- * engine.sql partial_where_for.
675
- */
676
- function partialWhereFor(indexes, table, on) {
677
- const norm = (text) => String(text).replace(/["\s]/g, '').toLowerCase();
678
- for (const index of indexes || []) {
679
- if (index.table_name !== table) continue;
680
- const m = /USING \w+ \(([^)]*)\)(?: WHERE (.*))?$/.exec(String(index.definition || ''));
681
- if (m && m[2] && norm(m[1]) === norm(on)) return m[2];
682
- }
683
- return null;
684
- }
685
-
686
- /**
687
- * The upserts Postgres refuses to run, asked on the copy.
688
- *
689
- * Found on a blind test (Northstar): upsert_project said
690
- * ON CONFLICT (organization_id, external_ref), and the only unique index on
691
- * those columns was partial (WHERE external_ref IS NOT NULL). Postgres can
692
- * only use a partial index when the ON CONFLICT repeats its WHERE, so it
693
- * refused the statement and the function failed on every call.
694
- *
695
- * Nothing of the app's is run. Each statement is put to the copy as
696
- * EXPLAIN INSERT ... DEFAULT VALUES ON CONFLICT (...) DO NOTHING - planned,
697
- * never executed - and only Postgres's own "no unique or exclusion constraint
698
- * matching" (42P10) counts. Any other error says nothing either way.
699
- */
700
- async function conflictsIn(client, into, plan, indexes) {
701
- const found = [];
702
- for (const t of conflictTargetsOf(plan)) {
703
- let refused = false;
704
- await client.query('BEGIN');
705
- try {
706
- await client.query('EXPLAIN INSERT INTO ' + schema.quote(into) + '.' + schema.quote(t.target) +
707
- ' DEFAULT VALUES ON CONFLICT (' + t.on + ')' + (t.where ? ' ' + t.where : '') + ' DO NOTHING');
708
- } catch (err) {
709
- refused = err.code === '42P10';
710
- } finally {
711
- await client.query('ROLLBACK').catch(() => {});
712
- }
713
- if (!refused) continue;
714
- found.push({
715
- kind: 'conflict',
716
- fn: t.fn,
717
- table: t.fn,
718
- args: t.args,
719
- target: t.target,
720
- on: t.on,
721
- partialWhere: partialWhereFor(indexes, t.target, t.on),
722
- columns: [],
723
- });
724
- }
725
- return found;
726
- }
727
-
728
- /**
729
- * The tables whose rules decide who is asking from a setting the app's own
730
- * server sets - current_setting('app.tenant_id') - rather than from a
731
- * Supabase sign-in. Each is a table NOT checked, said with the settings.
732
- *
733
- * Found on blind round 13 (Northstar Ops, Express on plain Postgres): every
734
- * rule read app.tenant_id / app.user_id, which nothing here sets. Every attack
735
- * ran as nobody, every one was refused for that reason alone, and the report
736
- * said "Nothing got through" over a table whose rule was USING (true) - the
737
- * one sentence this program must never print by accident. A rule that also
738
- * asks Supabase (auth.uid(), request.jwt...) is attacked as usual.
739
- * Mirrors server_identity in engine.sql.
740
- */
741
- function serverIdentityOf(plan) {
742
- const byTable = new Map();
743
- for (const p of (plan && plan.policies) || []) {
744
- const text = String(p.qual || '') + ' ' + String(p.with_check || '');
745
- if (/\bauth\s*\.\s*(uid|jwt|role|email)\s*\(|request\.jwt/i.test(text)) continue;
746
- const re = /current_setting\s*\(\s*'([^']+)'/gi;
747
- let m;
748
- while ((m = re.exec(text))) {
749
- if (/^request\./i.test(m[1])) continue;
750
- if (!byTable.has(p.table_name)) byTable.set(p.table_name, new Set());
751
- byTable.get(p.table_name).add(m[1]);
752
- }
753
- }
754
- return Array.from(byTable.keys()).sort().map((table) => {
755
- const settings = Array.from(byTable.get(table)).sort();
756
- // The why is short and per table; what it means is said once, under the
757
- // list (notTestedLines), not repeated for every table.
758
- return {
759
- table: table,
760
- why: 'its rules decide who is asking with ' + andList(settings.map((s) => "current_setting('" + s + "')")),
761
- identity: true,
762
- };
763
- });
764
- }
765
-
766
- /**
767
- * The app's functions that were read and never called: anything a visitor or
768
- * a signed-in user can call, that no rule calls for them (those ran with every
769
- * read), and that no finding already names. Said under "What I did not test".
770
- *
771
- * Found on a blind test (Northstar): the report on the repository said every
772
- * attack ran, while upsert_project - broken on every call - and record_scan
773
- * had never been run at all.
774
- */
775
- function notCalledOf(plan, findings) {
776
- const escape = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
777
- const rules = ((plan && plan.policies) || []).map((p) => String(p.qual || '') + ' ' + String(p.with_check || '')).join(' ');
778
- const named = new Set((findings || []).map((f) => f.fn).filter(Boolean));
779
- const names = ((plan && plan.functions) || [])
780
- .filter((fn) => fn.client_callable === true && fn.from_extension !== true && !named.has(fn.name))
781
- .map((fn) => fn.name)
782
- .filter((name) => !new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?\\s*\\(').test(rules));
783
- return Array.from(new Set(names)).sort();
784
- }
785
-
786
- /**
787
- * A public bucket whose read rule says it was meant to be private.
788
- *
789
- * Found on two blind tests (OrbitDesk, AtlasPay): a bucket of exports and one
790
- * of settlement evidence were switched to public. A public bucket hands any
791
- * file to whoever has its link, and the rules on storage.objects are not asked.
792
- * A rule that only lets in some people is how the app says the files are not
793
- * for everyone - so a public bucket with one is worth a look. A rule that lets
794
- * in everyone anyway (anon or public, and nothing but the bucket's name) says
795
- * the opposite, and is not reported.
796
- */
797
- function bucketsOf(plan) {
798
- const escape = (x) => String(x).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
799
- const narrows = (rule, id) => {
800
- const roles = schema.roleList(rule.roles);
801
- const toEveryone = roles.includes('anon') || roles.includes('public');
802
- const rest = String(rule.qual || '')
803
- .replace(new RegExp("bucket_id\\s*=\\s*'" + escape(id) + "'(::text)?", 'gi'), '')
804
- .replace(new RegExp("'" + escape(id) + "'(::text)?\\s*=\\s*bucket_id", 'gi'), '')
805
- .replace(/\btrue\b|\band\b|[()\s]/gi, '');
806
- return !toEveryone || rest !== '';
807
- };
808
- // A rule for putting files in or taking them out that only signed-in people
809
- // pass AND that looks a file up in the app's own data (a subquery over a
810
- // table: an attachments row, a membership). That is files the app treats as
811
- // its members' records. A rule that only asks whose folder it is - the
812
- // usual public avatars bucket - is not this.
813
- const membersOnly = (rule) => {
814
- const roles = schema.roleList(rule.roles);
815
- const text = String(rule.qual || '') + ' ' + String(rule.with_check || '');
816
- const open = roles.includes('anon') || roles.includes('public');
817
- return (!open || /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(text)) && /\bfrom\s+/i.test(text);
818
- };
819
- return ((plan && plan.buckets) || []).filter((b) => b.public).map((b) => {
820
- const writers = (b.writeRules || []).filter(membersOnly).map((rule) => rule.name);
821
- return {
822
- kind: 'bucket',
823
- table: b.name,
824
- bucket: b.id,
825
- rules: (b.readRules || []).filter((rule) => narrows(rule, b.id)).map((rule) => rule.name),
826
- // No read rule at all, and something else saying its files are not for
827
- // everyone: a name that says what is in it (HarborLine: customer-exports,
828
- // whose one read rule had failed to load), or rules that let only
829
- // signed-in members put files in or take them out (blind round 15:
830
- // workspace-files, written only through an attachments row of the
831
- // uploader's, and readable by anyone with a link).
832
- noRule: !(b.readRules || []).length && (PRIVATE_BUCKET.test(String(b.name)) || writers.length > 0),
833
- writers: !(b.readRules || []).length ? writers : [],
834
- columns: [],
835
- };
836
- }).filter((f) => f.rules.length || f.noRule);
837
- }
838
-
839
- // Names that say a bucket's files are somebody's own business. Whole words
840
- // only: "plain" or "avatars" say nothing, and stay quiet.
841
- const PRIVATE_BUCKET = /(^|[-_.])(exports?|private|documents?|docs|invoices?|contracts?|backups?|reports?|receipts?|statements?|payouts?|evidence|kyc|passports?|medical|records?|confidential|internal|secrets?|attachments?)([-_.]|$)/i;
842
-
843
- /**
844
- * What each finding needs to know about its table for its fix to be right:
845
- * whether the app signs people in through Supabase, whether a row says whose
846
- * it is, and which column that is. Without these a prompt falls back to "the
847
- * column that says who each row belongs to" - found on a nightly read-back,
848
- * whose findings came from the database without them.
849
- */
850
- function taggerFor(plan, tables) {
851
- const ties = finding.authTiesOf(plan);
852
- const owned = new Map((tables || []).map((table) => [table.name, attack.ownerColumn(table)]));
853
- return (f) => Object.assign({}, f,
854
- ties.has(f.table) ? { authTied: ties.get(f.table) } : {},
855
- owned.has(f.table) ? { owned: Boolean(owned.get(f.table)) } : {},
856
- owned.get(f.table) ? { ownedBy: owned.get(f.table) } : {});
857
- }
858
-
859
- /**
860
- * What the nightly engine wrote down as not tried, in the shape a scan uses.
861
- * It names a column's check as table and column apart, with no attack named,
862
- * so "export_jobs.access_token could not be raced" was counted as a whole
863
- * table not checked - and listed without its column.
864
- */
865
- function notCheckedFromNight(entries) {
866
- return (entries || []).map((e) => {
867
- if (!e || e.key || !e.column) return e;
868
- const kind = /two requests at the same instant/.test(String(e.why)) ? 'duplicated' : 'orphaned';
869
- return { table: e.table + '.' + e.column, key: kind + ':' + e.table + ':' + e.column, why: e.why };
870
- });
871
- }
872
-
873
- /**
874
- * A moment of the night, said in UTC - which is what the database runs on -
875
- * and in this computer's own time, so 03:00 is not read as three at night
876
- * here. Found on a real read-back: "the night of 2026-10-01 03:00" was 08:30
877
- * in India, and nothing said so.
878
- */
879
- function bothTimes(iso) {
880
- const at = new Date(iso);
881
- return at.toISOString().slice(0, 16).replace('T', ' ') + ' UTC (' +
882
- at.toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) + ' your time)';
883
- }
884
-
885
- /** "0 3 * * *" as a time of day in both, or the schedule as written. */
886
- function scheduleTimes(cron) {
887
- const m = /^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*$/.exec(String(cron || '').trim());
888
- if (!m) return String(cron || 'whenever it is scheduled');
889
- const now = new Date();
890
- const at = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), Number(m[2]), Number(m[1])));
891
- return 'every night at ' + at.toISOString().slice(11, 16) + ' UTC (' +
892
- at.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) + ' your time)';
893
- }
894
-
895
- /** Where the nightly run's last answer is kept for the dashboard. */
896
- function nightlyFile() {
897
- return store.open(process.cwd()).nightNightly;
898
- }
899
-
900
- /**
901
- * The nightly run's last answer, in the shape the dashboard reads: the same
902
- * described findings a scan saves, and the three states that are not the same
903
- * as "nothing found" - not installed, installed and never run, and stopped -
904
- * said as themselves. `latest` is installer.latestRun's answer.
905
- */
906
- function nightlyRecord(latest, readAt, plan) {
907
- const at = (value) => (value ? new Date(value).toISOString() : null);
908
- if (!latest || !latest.installed) return { readAt: readAt, installed: false };
909
- const where = latest.where || {};
910
- const record = {
911
- readAt: readAt,
912
- installed: true,
913
- scheduled: where.job ? where.job.schedule : null,
914
- active: Boolean(where.job && where.job.active),
915
- source: where.source || null,
916
- ranAt: null,
917
- };
918
- const run = latest.run;
919
- if (!run) return record;
920
- // The night's findings, told as the night's: tagged with what their table
921
- // needs for a right fix when the schema could be read, and joined by the
922
- // functions anyone can call and the public buckets - read now, when this is
923
- // asked, which is newer than the night. The night records them too since
924
- // 0.1.32 (for the dashboard), so where they can be read now the night's own
925
- // are left out rather than said twice; where they cannot, the night's stand.
926
- const tag = plan ? taggerFor(plan, plan.tables) : (f) => f;
927
- // A loop the engine could not name ("stack depth limit exceeded") is put
928
- // on the tables whose own rule reads back into them, read from the shape.
929
- const loops = plan ? loopingTables(plan) : [];
930
- const night = [];
931
- for (const f of run.findings || []) {
932
- if (plan && (f.kind === 'privileged' || f.kind === 'bucket')) continue;
933
- const at = f.kind === 'recursive' && !f.table ? (loops.length ? loops : ['one of your tables']) : [f.table];
934
- for (const table of at) night.push(tag(Object.assign({}, f, { table: table, fromNight: true })));
935
- }
936
- const now = plan ? privilegedOf(plan).concat(bucketsOf(plan)).map(tag) : [];
937
- return Object.assign(record, {
938
- ranAt: at(run.ran_at),
939
- source: run.source,
940
- stopped: run.stopped || null,
941
- attacksRun: run.attacks_run || 0,
942
- notChecked: notCheckedFromNight(run.not_checked),
943
- notCalled: run.not_called || [],
944
- findings: run.stopped ? [] : finding.describeAll(night.concat(now)),
945
- functionsReadAt: plan ? readAt : null,
946
- });
947
- }
948
-
949
- /** Kept for the dashboard. A failure here never touches the run itself. */
950
- function saveNightly(file, record) {
951
- try {
952
- fs.writeFileSync(file, JSON.stringify(record, null, 2));
953
- return true;
954
- } catch (err) {
955
- return false;
956
- }
957
- }
958
-
959
- /** The last run, or null if there has not been one worth keeping. */
960
- function loadLastRun(file) {
961
- try {
962
- const saved = JSON.parse(fs.readFileSync(file, 'utf8'));
963
- return saved && Array.isArray(saved.findings) ? saved : null;
964
- } catch (err) {
965
- return null;
966
- }
967
- }
968
-
969
- /**
970
- * Keeps a run so the next one can be measured against it.
971
- *
972
- * A run that stopped is never saved. Overwriting a real list of problems with
973
- * an empty one from a run that fell over would quietly lose every finding, and
974
- * the re-check after that would have nothing to hold anybody to.
975
- */
976
- function saveRun(file, result) {
977
- if (result.stopped) return;
978
- try {
979
- fs.writeFileSync(file, JSON.stringify(result, null, 2));
980
- } catch (err) {
981
- line(' WARNING: I could not save this run to ' + file + ', so tomorrow I');
982
- line(' will have nothing to compare against: ' + err.message);
983
- }
984
- }
985
-
986
- /**
987
- * The last thing on the screen: what was not tested, and why.
988
- *
989
- * A report that ends on "nothing got through" is read as "I am safe", and the
990
- * distance between those two sentences is the whole product. So the last
991
- * words are always the limits of the answer, not the answer.
992
- *
993
- * It is printed even when nothing was missed, because "I tested all of it"
994
- * and "I did not tell you what I skipped" look identical when the section is
995
- * simply absent, and only one of them is good news.
996
- */
997
- function notTestedLines(result) {
998
- const notChecked = result.notChecked || [];
999
- const lines = ['', ' ' + '-'.repeat(68), ' What I did not test', ''];
1000
-
1001
- if (!notChecked.length) {
1002
- lines.push(' Every table I could reach was attacked, and every attack I have');
1003
- lines.push(' finished, ran.');
1004
- } else {
1005
- // Wrapped: a reason can be a sentence or two (serverIdentityOf), and an
1006
- // unwrapped one ran off the side of the terminal and the dashboard.
1007
- for (const missed of notChecked) {
1008
- wrap(missed.table + ' - ' + missed.why, 66).forEach((l, i) => lines.push((i ? ' ' : ' ') + l));
1009
- }
1010
- if (notChecked.some((missed) => missed.identity)) {
1011
- lines.push('');
1012
- wrap('Those settings are set by your own server, not by a Supabase sign-in. I sign in the way Supabase ' +
1013
- 'does, so every attack on those tables ran as nobody, and being refused proves nothing about them. ' +
1014
- 'Check those rules by hand, or test them with your server setting who is asking.', 66)
1015
- .forEach((l) => lines.push(' ' + l));
1016
- }
1017
- lines.push('');
1018
- lines.push(' These are unknown, not clear. An attack that never ran comes back');
1019
- lines.push(' looking exactly like an attack that was refused.');
1020
- }
1021
-
1022
- // Found on a blind test (Northstar): "every attack ran" was printed over an
1023
- // upsert that failed on every call, because no attack calls the app's own
1024
- // functions. They are named, so the sentence above is read as being about
1025
- // tables only.
1026
- const notCalled = result.notCalled || [];
1027
- if (notCalled.length) {
1028
- lines.push('');
1029
- wrap('Your app\'s ' + (notCalled.length === 1 ? 'function ' : 'functions ') +
1030
- andList(notCalled.map((name) => name + '()')) + (notCalled.length === 1 ? ' was' : ' were') +
1031
- ' read, never called. Whether ' + (notCalled.length === 1 ? 'it works' : 'they work') +
1032
- ', and what ' + (notCalled.length === 1 ? 'it lets' : 'they let') + ' a caller do, is not tested here.', 68)
1033
- .forEach((l) => lines.push(' ' + l));
1034
- }
1035
-
1036
- // Said every time, whether or not the collision attack ran. It is the one
1037
- // limit that is structural rather than circumstantial, and a person
1038
- // deciding how far to trust this deserves to know it is there.
1039
- lines.push('');
1040
- lines.push(' One thing is never reported, on purpose: the lost update - two');
1041
- lines.push(' withdrawals of 100 from a balance of 100 that both go through.');
1042
- lines.push(' Every Postgres database behaves that way unless the app asks it');
1043
- lines.push(' not to, so whether yours is affected depends on code I never see.');
1044
- lines.push(' Flagging it would mean flagging every app with a number in it.');
1045
- lines.push('');
1046
-
1047
- return lines;
1048
- }
1049
-
1050
- /**
1051
- * The warning above the verdict: how much went untested, counted honestly.
1052
- *
1053
- * Every entry used to be counted as a table. Found on a benchmark: one
1054
- * column's duplicate test did not run, and the top line said "1 table was
1055
- * NOT checked" about a table every other attack had covered - and a table
1056
- * with two attacks stuck was counted as two tables. An entry with no attack
1057
- * named is a whole table nothing could be put into; one with an attack named
1058
- * is part of a table.
1059
- */
1060
- function missedLines(notChecked) {
1061
- const tableOf = (entry) => String(entry.table).split('.')[0];
1062
- const whole = new Set(notChecked.filter((entry) => !entry.key).map(tableOf));
1063
- const parts = notChecked.filter((entry) => entry.key && !whole.has(tableOf(entry)));
1064
- const partly = new Set(parts.map(tableOf));
1065
- const tables = (n) => n + (n === 1 ? ' table' : ' tables');
1066
- const said = [];
1067
- if (whole.size) said.push(tables(whole.size) + (whole.size === 1 ? ' was' : ' were') + ' NOT checked');
1068
- if (parts.length) {
1069
- said.push(parts.length + (parts.length === 1 ? ' check' : ' checks') + ' on ' +
1070
- (whole.size ? tables(partly.size).replace(/ table/, ' other table') : tables(partly.size)) +
1071
- (parts.length === 1 ? ' was' : ' were') + ' NOT run');
1072
- }
1073
- const it = notChecked.length === 1 ? 'it' : 'them';
1074
- return wrap(said.join(', and ') + '. Whatever this report says next is not about ' + it + '. Treat ' +
1075
- it + ' as unknown, not as clear - the list, and why, is at the end.', 68).map((l) => ' ' + l);
1076
- }
1077
-
1078
- /** The report, as the person reads it in the morning. */
1079
- function report(result) {
1080
- if (result.stopped) {
1081
- line('');
1082
- line(' I could not check this app.');
1083
- line('');
1084
- line(' ' + result.stopped);
1085
- line('');
1086
- return;
1087
- }
1088
-
1089
- // Said before anything else, and said even on a clean night. "Everything
1090
- // held" is only true about the tables that were actually tried, and a table
1091
- // no row could be put into looks exactly like a table nothing got out of.
1092
- const notChecked = result.notChecked || [];
1093
- // Said before the verdict, and again in full at the very end. The warning
1094
- // has to come first or "everything held" is read before the reason it might
1095
- // not mean anything; the list has to come last or it is the first thing
1096
- // scrolled past. The two are not the same sentence twice - this one is
1097
- // whether to trust the verdict, and the one at the end is what to go and
1098
- // look at.
1099
- const sayWhatWasMissed = () => {
1100
- if (!notChecked.length) return;
1101
- line('');
1102
- missedLines(notChecked).forEach(line);
1103
- };
1104
-
1105
- if (!result.findings.length) {
1106
- if (notChecked.length) {
1107
- sayWhatWasMissed();
1108
- line('');
1109
- line(' Everything I could try, held.');
1110
- line('');
1111
- } else {
1112
- finding.allClearLines(result.attacksRun).forEach(line);
1113
- }
1114
- notTestedLines(result).forEach(line);
1115
- return;
1116
- }
1117
-
1118
- sayWhatWasMissed();
1119
-
1120
- // Confirmed breaks and things that only need a look are not the same claim,
1121
- // so they are counted apart. Saying "3 problems" when one of them was never
1122
- // executed is the kind of overstatement that costs the next report its trust.
1123
- const confirmed = result.findings.filter((f) => f.status !== 'verification required');
1124
- const toVerify = result.findings.filter((f) => f.status === 'verification required');
1125
- line('');
1126
- if (confirmed.length) {
1127
- line(' ' + confirmed.length + (confirmed.length === 1 ? ' problem' : ' problems') + ' found.');
1128
- }
1129
- if (toVerify.length) {
1130
- // A viewer who can edit was really tried; a function or a bucket was only
1131
- // read. Either way it is the owner's call, and the line says which.
1132
- const tried = toVerify.some((f) => f.kind === 'role' || f.kind === 'teamread');
1133
- const spotted = toVerify.some((f) => f.kind !== 'role' && f.kind !== 'teamread');
1134
- const it = toVerify.length === 1 ? 'it' : 'them';
1135
- line(' ' + toVerify.length + ' thing' + (toVerify.length === 1 ? '' : 's') + ' to check - ' +
1136
- (spotted && !tried ? 'I did not attack ' + it + ', only spotted the risk.'
1137
- : 'whether ' + (toVerify.length === 1 ? 'it is a problem' : 'each is a problem') + ' is your decision.'));
1138
- }
1139
-
1140
- for (const item of result.findings) {
1141
- line('');
1142
- line(' ' + '-'.repeat(68));
1143
- line(' ' + item.severity + ' ' + item.table +
1144
- (item.status === 'verification required' ? ' (verification required)' : ''));
1145
- line('');
1146
- line(' ' + item.headline);
1147
- line('');
1148
- for (const paragraph of [item.body, item.cause].filter(Boolean)) {
1149
- wrap(paragraph, 70).forEach((l) => line(' ' + l));
1150
- line('');
1151
- }
1152
- line(' Paste this into Lovable, Claude or Cursor:');
1153
- line('');
1154
- item.fixPrompt.split('\n').forEach((l) => line(' ' + l));
1155
- }
1156
-
1157
- line('');
1158
- line(' ' + '-'.repeat(68));
1159
- line(' Nothing here touched your live app. I built a copy, attacked the copy,');
1160
- line(' and deleted it. Not one real customer was involved.');
1161
-
1162
- notTestedLines(result).forEach(line);
1163
- }
1164
-
1165
- /** Wraps at a width a person can read without their eyes sliding off. */
1166
- /** a, b and c */
1167
- function andList(items) {
1168
- return items.length < 2 ? items.join('') : items.slice(0, -1).join(', ') + ' and ' + items[items.length - 1];
1169
- }
1170
-
1171
- function wrap(text, width) {
1172
- const words = String(text).split(/\s+/);
1173
- const lines = [];
1174
- let current = '';
1175
- for (const word of words) {
1176
- if ((current + ' ' + word).trim().length > width) {
1177
- lines.push(current.trim());
1178
- current = word;
1179
- } else {
1180
- current = (current + ' ' + word).trim();
1181
- }
1182
- }
1183
- if (current) lines.push(current.trim());
1184
- return lines;
1185
- }
1186
-
1187
- async function main() {
1188
- const args = process.argv.slice(2).filter((arg) => arg !== '--recheck');
1189
- const again = process.argv.includes('--recheck');
1190
-
1191
- // The connection string is the key to the customer's whole database, and
1192
- // typed on the command line it goes into shell history and into the process
1193
- // list where anybody on the machine can read it. The environment variable is
1194
- // the way it should be given; the argument still works because taking it
1195
- // away would break anyone already using it.
1196
- const fromEnvironment = process.env.KN_DATABASE_URL;
1197
- const connection = args.length > 1 ? args[0] : fromEnvironment;
1198
- const target = args.length > 1 ? args[1] : args[0];
1199
-
1200
- if (!connection || !target) {
1201
- console.error('');
1202
- console.error(' KN_DATABASE_URL=postgresql://... node scan.js <schema> [--recheck]');
1203
- console.error(' node scan.js "<connection string>" <schema> [--recheck]');
1204
- console.error('');
1205
- console.error(' <schema> is usually "public".');
1206
- console.error('');
1207
- process.exit(2);
1208
- }
1209
-
1210
- let file;
1211
- try {
1212
- file = lastRunFile(line);
1213
- } catch (err) {
1214
- // 2, not 1: nothing was attacked, so nothing "got through".
1215
- console.error('');
1216
- console.error(' There is nowhere to keep this run (' + err.message + ').');
1217
- console.error(' Set KRYPTHEON_HOME to a folder you can write to, and run this again.');
1218
- console.error('');
1219
- process.exit(2);
1220
- }
1221
- const before = again ? loadLastRun(file) : null;
1222
- if (again && !before) {
1223
- // Running a fresh scan and calling it a re-check would report every
1224
- // problem as new and confirm nothing, which reads like an answer.
1225
- console.error('');
1226
- console.error(' There is no earlier run here to compare against.');
1227
- console.error('');
1228
- console.error(' Run it without --recheck first, fix what it finds, then come back.');
1229
- console.error('');
1230
- process.exit(2);
1231
- }
1232
-
1233
- const client = new Client({ connectionString: connection });
1234
- await client.connect();
1235
- try {
1236
- const result = await scan(client, target, {
1237
- // Two requests arriving together cannot be faked down one connection, so
1238
- // the collision attack is handed a way to open its own.
1239
- openSession: async () => {
1240
- const extra = new Client({ connectionString: connection });
1241
- await extra.connect();
1242
- return extra;
1243
- },
1244
- });
1245
-
1246
- if (!before) {
1247
- report(result);
1248
- saveRun(file, result);
1249
- // Three answers, three codes: 0 clean, 1 problems found, 2 could not
1250
- // run. A scan that stopped used to exit 0 with no findings, which is
1251
- // what any script watching it would read as a pass.
1252
- process.exitCode = exitCodeFor(result);
1253
- return;
1254
- }
1255
-
1256
- const verdict = recheck.compare(before, result);
1257
- recheck.describe(verdict).forEach(line);
1258
- recheck.badgeLines(verdict, result.attacksRun || 0).forEach(line);
1259
- // The verdict says what changed; this says what to do about what did not.
1260
- if (result.findings.length) report(result);
1261
- else notTestedLines(result).forEach(line);
1262
- saveRun(file, result);
1263
- // Two different databases: the verdict is a comparison, so the exit code
1264
- // is this run's own answer, the same as a first run.
1265
- process.exitCode = result.stopped ? 2 : verdict.elsewhere ? exitCodeFor(result) : verdict.allClear ? 0 : 1;
1266
- } finally {
1267
- await client.end();
1268
- }
1269
- }
1270
-
1271
- if (require.main === module) {
1272
- main().catch((err) => {
1273
- console.error('');
1274
- console.error(' The scan could not run: ' + err.message);
1275
- console.error('');
1276
- process.exit(1);
1277
- });
1278
- }
1279
-
1280
- module.exports = {
1281
- scan: scan,
1282
- report: report,
1283
- missedLines: missedLines,
1284
- wrap: wrap,
1285
- loadLastRun: loadLastRun,
1286
- saveRun: saveRun,
1287
- nightlyFile: nightlyFile,
1288
- nightlyRecord: nightlyRecord,
1289
- bothTimes: bothTimes,
1290
- scheduleTimes: scheduleTimes,
1291
- notCheckedFromNight: notCheckedFromNight,
1292
- privilegedOf: privilegedOf,
1293
- searchPathsOf: searchPathsOf,
1294
- emptyChoicesOf: emptyChoicesOf,
1295
- noInsertsOf: noInsertsOf,
1296
- oneOfColumns: oneOfColumns,
1297
- conflictTargetsOf: conflictTargetsOf,
1298
- partialWhereFor: partialWhereFor,
1299
- conflictsIn: conflictsIn,
1300
- notCalledOf: notCalledOf,
1301
- serverIdentityOf: serverIdentityOf,
1302
- bucketsOf: bucketsOf,
1303
- loopingTables: loopingTables,
1304
- taggerFor: taggerFor,
1305
- saveNightly: saveNightly,
1306
- sweepOldCopies: sweepOldCopies,
1307
- notTestedLines: notTestedLines,
1308
- lastRunFile: lastRunFile,
1309
- exitCodeFor: exitCodeFor,
1310
- ABANDONED_AFTER: ABANDONED_AFTER,
1311
- };
1
+ // One night's work, end to end.
2
+ //
3
+ // read the app's shape -> rebuild it somewhere safe -> seed two fake
4
+ // people -> attack the copy -> say what got through -> delete the copy
5
+ //
6
+ // The live app is read and never written to. Nothing is copied except the
7
+ // shape: no rows, no customers, no orders. Every row the attack reads is a row
8
+ // this tool put there itself, which is what makes the report safe to send.
9
+ //
10
+ // Run with:
11
+ // KN_DATABASE_URL=postgresql://... node scan.js <schema>
12
+ // KN_DATABASE_URL=postgresql://... node scan.js <schema> --recheck
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { Client } = require('pg');
17
+ const schema = require('./schema.js');
18
+ const attack = require('./attack.js');
19
+ const finding = require('./finding.js');
20
+ const collision = require('./collision.js');
21
+ const tamper = require('./tamper.js');
22
+ const orphan = require('./orphan.js');
23
+ const recheck = require('./recheck.js');
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
+ }
44
+
45
+ const line = (text) => process.stdout.write(text + '\n');
46
+
47
+ // How long a copy has to be lying around before it is treated as abandoned.
48
+ // Long enough that a scan running in another window is never swept out from
49
+ // under itself; short enough that nobody finds week-old schemas in their
50
+ // database.
51
+ const ABANDONED_AFTER = 6 * 60 * 60 * 1000;
52
+
53
+ /**
54
+ * Drops copies an earlier run left behind.
55
+ *
56
+ * The copy's name carries the moment it was made, so its age can be read
57
+ * without asking Postgres - which does not record when a schema was created.
58
+ * Anything younger than a few hours is left strictly alone: it may well belong
59
+ * to a scan that is running right now.
60
+ */
61
+ async function sweepOldCopies(client, mine) {
62
+ const dropped = [];
63
+ let rows = [];
64
+ try {
65
+ ({ rows } = await client.query(
66
+ // Copies, and the engine schemas the SQL door installs. A copy is
67
+ // kn_<moment>; an engine is kn_engine_<moment>. The engine used to be
68
+ // outside this pattern entirely, so one abandoned by a dropped
69
+ // connection sat in the customer's database for ever - which is the
70
+ // one thing this product promises never to do.
71
+ "SELECT nspname FROM pg_namespace WHERE nspname ~ '^kn_(engine_)?[0-9a-z]+$' AND nspname <> $1",
72
+ [mine],
73
+ ));
74
+ } catch (err) {
75
+ return dropped;
76
+ }
77
+
78
+ for (const row of rows) {
79
+ // The moment is whatever follows the last underscore, which is the
80
+ // whole name after kn_ for a copy and the tail for an engine.
81
+ const parts = String(row.nspname).split('_');
82
+ const made = parseInt(parts[parts.length - 1], 36);
83
+ if (!Number.isFinite(made) || Date.now() - made < ABANDONED_AFTER) continue;
84
+ try {
85
+ await client.query('DROP SCHEMA IF EXISTS ' + schema.quote(row.nspname) + ' CASCADE');
86
+ dropped.push(row.nspname);
87
+ } catch (err) {
88
+ // Not ours to force. Better to leave it than to fail somebody's scan.
89
+ }
90
+ }
91
+ return dropped;
92
+ }
93
+
94
+ /**
95
+ * Every schema in the database, and how many tables are in each.
96
+ *
97
+ * Nothing filtered out. The first version left out anything named like one of
98
+ * our copies, which made the scan refuse to look at a schema whose name merely
99
+ * began the same way - and that took the entire shapes suite down a minute
100
+ * after this function was written. What to hide is a question for the
101
+ * suggestion below, not for whether a schema exists.
102
+ */
103
+ async function schemasWithTables(client) {
104
+ const { rows } = await client.query(
105
+ `SELECT n.nspname AS schema, count(c.oid) FILTER (WHERE c.relkind = 'r')::int AS tables
106
+ FROM pg_namespace n
107
+ LEFT JOIN pg_class c ON c.relnamespace = n.oid
108
+ WHERE n.nspname NOT LIKE 'pg\\_%'
109
+ AND n.nspname <> 'information_schema'
110
+ GROUP BY 1
111
+ ORDER BY 2 DESC, 1`,
112
+ );
113
+ return rows;
114
+ }
115
+
116
+ /**
117
+ * Where to look when nobody said (blind round 17): "public" when it has
118
+ * tables; otherwise the one schema of the app's that does - not Postgres's
119
+ * own, Supabase's, or this tool's (schema.PLATFORM_SCHEMAS, OWN_SCHEMA, the
120
+ * nightly install). Two or more, or none, and "public" stays: the scan then
121
+ * stops and lists them, because choosing between apps is not a guess to make.
122
+ * Returns { schema, chosen } - chosen when it is not "public".
123
+ */
124
+ async function pickSchema(client, installed) {
125
+ const own = new RegExp(schema.OWN_SCHEMA);
126
+ const all = await schemasWithTables(client);
127
+ const pub = all.find((entry) => entry.schema === 'public');
128
+ if (pub && pub.tables > 0) return { schema: 'public', chosen: false };
129
+ const apps = all.filter((entry) => entry.tables > 0 && entry.schema !== 'public' && !own.test(entry.schema) &&
130
+ !schema.PLATFORM_SCHEMAS.includes(entry.schema) && entry.schema !== (installed || 'kryptheon'));
131
+ return apps.length === 1 ? { schema: apps[0].schema, chosen: true } : { schema: 'public', chosen: false };
132
+ }
133
+
134
+ /**
135
+ * What to try instead.
136
+ *
137
+ * A person who mistyped a schema name is about to mistype it again. Listing
138
+ * what is actually there costs one query and saves the second attempt - but
139
+ * not the throwaway copies, which are ours and about to be deleted.
140
+ */
141
+ function suggest(schemas) {
142
+ const worth = schemas.filter((entry) => entry.tables > 0 && !/^kn_[0-9a-z]+$/.test(entry.schema));
143
+ if (!worth.length) return '\n This database has no schema with tables in it.';
144
+ return '\n Schemas in this database with tables in them: ' +
145
+ worth.map((entry) => entry.schema + ' (' + entry.tables + ')').join(', ');
146
+ }
147
+
148
+ /**
149
+ * The engine, as seven operations, so the scan does not know which one it has.
150
+ *
151
+ * `scan.js` named `schema.js`, `attack.js`, `tamper.js` and `orphan.js`
152
+ * directly, which meant the SQL engine could only ever be reached by the twin
153
+ * check. Two engines and one of them never on the real path is how the unused
154
+ * one rots however good the comparison is.
155
+ *
156
+ * Deliberately not included: the collision race. It needs two requests in
157
+ * flight at once, which needs two connections, and no credential ever moves
158
+ * into the database - so the SQL engine cannot have it and says so. The scan
159
+ * keeps racing for real down `openSession` whichever engine it is given, and
160
+ * the report says what could not be raced when there is no second connection.
161
+ * That is the one place the two doors are honestly not the same, and it is
162
+ * named rather than papered over.
163
+ */
164
+ function nodeEngine() {
165
+ return {
166
+ name: 'node',
167
+ readSchema: (client, target) => schema.readSchema(client, target),
168
+ writeSchema: (client, plan, into) => schema.writeSchema(client, plan, into),
169
+ seed: (client, into, tables) => attack.seed(client, into, tables),
170
+ impersonate: (client, into, tables) => attack.impersonate(client, into, tables),
171
+ tamper: (client, into, tables, seeded, policies) =>
172
+ tamper.tamper(client, into, tables, seeded, policies),
173
+ orphan: (client, into, tables, seeded) => orphan.orphan(client, into, tables, seeded),
174
+ teammate: (client, into, tables, seeded, rules) => tamper.teammate(client, into, tables, seeded, rules),
175
+ conflicts: (client, into, plan, indexes) => conflictsIn(client, into, plan, indexes),
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Runs the whole thing and hands back what got through.
181
+ *
182
+ * The copy is dropped in a `finally`, so a crash halfway does not leave a
183
+ * database behind with somebody's schema in it.
184
+ */
185
+ async function scan(client, sourceSchema, options) {
186
+ const opts = options || {};
187
+ const copyName = 'kn_' + Date.now().toString(36);
188
+ const say = opts.quiet ? () => {} : line;
189
+ // Whichever engine the caller handed over, or the one built in.
190
+ const engine = opts.engine || nodeEngine();
191
+
192
+ // Anything an earlier run could not clean up after itself. The copy is
193
+ // dropped in a `finally`, but a `finally` needs a connection: when the link
194
+ // dies mid-scan the process goes with it and the copy is simply left in the
195
+ // customer's database. Found by exactly that happening on a real schema.
196
+ const swept = await sweepOldCopies(client, copyName);
197
+ if (swept.length) say(' Cleared ' + swept.length + ' copy left by an earlier run.');
198
+
199
+ // Is there anything here at all?
200
+ //
201
+ // Typed `pubic` instead of `public`, this used to read a schema that does
202
+ // not exist, find no tables, attack none of them, and print "Nothing got
203
+ // through. Your data held." A typo produced the one sentence the entire
204
+ // product is sold on, and exited 0 so a script would call it a pass.
205
+ const elsewhere = await schemasWithTables(client);
206
+ const here = elsewhere.find((entry) => entry.schema === sourceSchema);
207
+ if (!here) {
208
+ return {
209
+ stopped: 'There is no schema called "' + sourceSchema + '" in this database.' +
210
+ suggest(elsewhere),
211
+ findings: [],
212
+ };
213
+ }
214
+
215
+ say(' Reading the shape of ' + sourceSchema + ' ...');
216
+ const plan = await engine.readSchema(client, sourceSchema);
217
+
218
+ if (!plan.tables.length) {
219
+ // Nothing wrong with the database, but nothing was tested either, and
220
+ // those are not the same answer.
221
+ return {
222
+ stopped: 'The schema "' + sourceSchema + '" has no tables in it, so there was nothing to attack.' +
223
+ suggest(elsewhere.filter((entry) => entry.schema !== sourceSchema)),
224
+ findings: [],
225
+ };
226
+ }
227
+
228
+ if (plan.unsupported.length) {
229
+ // Attacking a copy that is missing pieces would produce verdicts about a
230
+ // database nobody runs, so this stops rather than guesses.
231
+ return {
232
+ stopped: 'Parts of this app could not be copied faithfully:\n ' + plan.unsupported.join('\n '),
233
+ findings: [],
234
+ };
235
+ }
236
+
237
+ say(' ' + plan.tables.length + ' tables, ' + plan.policies.length + ' rules. Building a copy ...');
238
+
239
+ try {
240
+ await engine.writeSchema(client, plan, copyName);
241
+ const copyPlan = await engine.readSchema(client, copyName);
242
+
243
+ // The copy has to be the app, or nothing that follows means anything.
244
+ const differences = schema.diffSchemas(plan, copyPlan);
245
+ if (differences.length) {
246
+ return {
247
+ stopped: 'The copy did not come out identical, so nothing was attacked:\n ' + differences.join('\n '),
248
+ findings: [],
249
+ };
250
+ }
251
+
252
+ // Upserts Postgres would refuse, asked of the empty copy before anything
253
+ // is put in it - planned, never run (conflictsIn).
254
+ const refused = engine.conflicts ? await engine.conflicts(client, copyName, plan, copyPlan.indexes) : [];
255
+
256
+ say(' Copy matches. Seeding two people and attacking it ...');
257
+ // The stand-ins built for tables outside the schema belong to this tool,
258
+ // not to the customer. Attacking them would produce findings about a table
259
+ // that does not exist in their app.
260
+ // Named one by one from what was actually built, not matched on a prefix.
261
+ // A customer table whose name merely looked like ours used to be dropped
262
+ // from every attack, and a table nobody attacks has nothing to report.
263
+ const standIns = new Set((plan.external || []).map((entry) => entry.stub));
264
+ const theirs = copyPlan.tables.filter((table) => !standIns.has(table.name));
265
+ const sown = await engine.seed(client, copyName, theirs);
266
+
267
+ // Views are read but never seeded: they have no rows of their own, they
268
+ // show the rows of the tables underneath. That is exactly why they matter
269
+ // - a view runs with its creator's rights unless it says otherwise, so one
270
+ // over a protected table hands out every row in it while the policy sits
271
+ // there intact and the dashboard stays green.
272
+ const views = (copyPlan.views || []).map((view) => ({
273
+ name: view.name,
274
+ columns: view.columns || [],
275
+ constraints: [],
276
+ rlsEnabled: false,
277
+ isView: true,
278
+ }));
279
+ const impersonation = await engine.impersonate(client, copyName, theirs.concat(views));
280
+
281
+ // Every attack genuinely run, named the way a finding is named. The
282
+ // re-check needs this: a finding that disappears because its attack never
283
+ // ran this time is not a finding that was fixed, and without this list the
284
+ // two are indistinguishable.
285
+ //
286
+ // Two conditions, both required. The read has to have produced a verdict,
287
+ // AND the table has to have had a row in it - because a table nothing
288
+ // could be seeded into returns nothing to a stranger for a reason that has
289
+ // nothing to do with being secure.
290
+ const sownTables = new Set(sown.seeded.map((entry) => entry.table));
291
+ // A view counts as tested once anything underneath it has rows, since that
292
+ // is what it has to show.
293
+ const viewNames = new Set(views.map((view) => view.name));
294
+ const attempted = impersonation.completed.filter((key) => {
295
+ const name = key.split(':')[1];
296
+ // Whether a rule loops needs no rows: Postgres finds the loop while
297
+ // planning the read, before it looks at a single row. A table nothing
298
+ // could be seeded into has still been checked for that.
299
+ if (key.startsWith('recursive:')) return true;
300
+ return sownTables.has(name) || (viewNames.has(name) && sown.seeded.length > 0);
301
+ });
302
+
303
+ // Surfaced, not swallowed. A table with no row in it reads as a safe
304
+ // table, so the one thing that must never happen is reporting an app as
305
+ // clear when part of it was never actually tried.
306
+ const notChecked = sown.skipped.slice().concat(serverIdentityOf(plan));
307
+
308
+ // A read that errored for any reason other than the table being closed to
309
+ // that role decided nothing at all, and zero rows back from a failed query
310
+ // looks exactly like zero rows back from a table that held.
311
+ for (const stuck of impersonation.blocked) {
312
+ if (!sownTables.has(stuck.table) && !viewNames.has(stuck.table)) continue;
313
+ notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
314
+ }
315
+
316
+ // Can a stranger change any of it? Every write here is rolled back, so the
317
+ // copy comes out of this exactly as it went in and anything running after
318
+ // reads the same database the earlier attacks did.
319
+ say(' Trying to change data that is not ours ...');
320
+ // The copy's own policies, not the app's - they are the same rules, and
321
+ // the copy is what was actually attacked. The report needs them to say
322
+ // which rule let a write through rather than assuming one.
323
+ const writes = await engine.tamper(client, copyName, theirs, sown.seeded, copyPlan.policies);
324
+ for (const key of writes.completed) attempted.push(key);
325
+ for (const stuck of writes.blocked) {
326
+ notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
327
+ }
328
+
329
+ // What the least trusted member of a team can change there. Rolled back
330
+ // like the writes above, and the joining with them.
331
+ // With the copy's rules and helpers, so a role a rule lists as allowed to
332
+ // write is not reported (tamper.allowedByRules).
333
+ const ranked = await engine.teammate(client, copyName, theirs, sown.seeded,
334
+ { policies: copyPlan.policies, functions: copyPlan.functions });
335
+ for (const key of ranked.completed) attempted.push(key);
336
+ for (const stuck of ranked.blocked) {
337
+ notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
338
+ }
339
+
340
+ // Can a half-finished write survive? Rolled back like the writes above.
341
+ say(' Looking for rows that could point at nothing ...');
342
+ const stranded = await engine.orphan(client, copyName, theirs, sown.seeded);
343
+ for (const key of stranded.completed) attempted.push(key);
344
+ for (const missed of stranded.notTried) {
345
+ notChecked.push({
346
+ table: missed.table + '.' + missed.column,
347
+ key: 'orphaned:' + missed.table + ':' + missed.column,
348
+ why: missed.why,
349
+ });
350
+ }
351
+
352
+ // A rule that looks itself up. The attacks it stopped stay in the list of
353
+ // what was not tested - they did not run - but the loop is also a finding
354
+ // of its own: every signed-in request that touches the table fails, so the
355
+ // app is broken for the people using it. One finding per table at fault,
356
+ // named from Postgres's own message, however many reads ran into it.
357
+ const loops = new Map();
358
+ // A loop that named no table ("stack depth limit exceeded") is put on the
359
+ // tables whose own rule calls a helper that reads that same table; if
360
+ // none can be found, on the table that was being read.
361
+ const culprits = loopingTables(copyPlan);
362
+ const loopedInto = (relation, table, who) => {
363
+ const deep = !relation;
364
+ for (const at of relation ? [relation] : culprits.length ? culprits : [table]) {
365
+ if (!loops.has(at)) loops.set(at, { kind: 'recursive', table: at, reads: [], callers: [], columns: [], deep: false });
366
+ const entry = loops.get(at);
367
+ if (deep) entry.deep = true;
368
+ if (!entry.reads.includes(table)) entry.reads.push(table);
369
+ if (!entry.callers.includes(who)) entry.callers.push(who);
370
+ }
371
+ };
372
+ for (const hit of impersonation.looped || []) loopedInto(hit.relation, hit.table, hit.who);
373
+ for (const stuck of writes.blocked) {
374
+ const relation = attack.recursionIn(stuck.why);
375
+ if (relation || attack.tooDeep(stuck.why)) loopedInto(relation || null, stuck.table, /^as anyone:/.test(stuck.why) ? 'anyone' : 'signed-in');
376
+ }
377
+ const recursive = Array.from(loops.values());
378
+
379
+ let collisions = { findings: [], notTried: [], raced: [] };
380
+ if (opts.openSession) {
381
+ say(' Racing two requests against each other ...');
382
+ const one = await opts.openSession();
383
+ const two = await opts.openSession();
384
+ try {
385
+ collisions = await collision.collide(client, one, two, copyName, theirs, copyPlan.indexes);
386
+ } finally {
387
+ await one.end().catch(() => {});
388
+ await two.end().catch(() => {});
389
+ }
390
+ for (const raced of collisions.raced) {
391
+ attempted.push('duplicated:' + raced.table + ':' + raced.column);
392
+ }
393
+ for (const missed of collisions.notTried) {
394
+ notChecked.push({
395
+ table: missed.table + '.' + missed.column,
396
+ key: 'duplicated:' + missed.table + ':' + missed.column,
397
+ why: missed.why,
398
+ });
399
+ }
400
+ } else {
401
+ // Two requests at once needs two connections. Without them the attack
402
+ // cannot happen at all, and a report that quietly omits it reads exactly
403
+ // like a report that ran it and found nothing.
404
+ for (const target of collision.candidates(theirs, copyPlan.indexes).filter((t) => !t.covered)) {
405
+ notChecked.push({
406
+ table: target.table + '.' + target.column,
407
+ key: 'duplicated:' + target.table + ':' + target.column,
408
+ why: 'racing two requests needs a second connection, and none was available',
409
+ });
410
+ }
411
+ }
412
+
413
+ // Where people actually sign in decides how bad a duplicate email is. On
414
+ // Supabase that is auth.users, which keeps emails unique; a public table's
415
+ // email column is then a copy. Only read, never written.
416
+ const loginElsewhere = (await client.query("select to_regclass('auth.users') is not null as found")).rows[0].found;
417
+ const raced = collisions.findings.map((f) =>
418
+ f.expectation === 'identity' ? Object.assign({}, f, { loginElsewhere: loginElsewhere }) : f,
419
+ );
420
+
421
+ // Functions an anonymous visitor can call that run past the rules. Read
422
+ // from the shape, not attacked: nothing is executed, because whether such
423
+ // a function is meant to be public is a question only the owner answers.
424
+ //
425
+ // Every definer function read this run counts as examined, whether or not
426
+ // anon can call it. That is what lets the re-check call a revoked grant
427
+ // fixed: the function was looked at again and the reach was gone. The first
428
+ // version recorded only the flagged ones, so a revoke made the function
429
+ // vanish from both lists and the fix came back as "could not confirm".
430
+ for (const fn of plan.anonFunctions || []) attempted.push('privileged:' + fn.name);
431
+ const privileged = privilegedOf(plan);
432
+
433
+ // How the app's functions and columns are declared: an open search_path,
434
+ // a list a CHECK keeps that still takes an empty value. Read from the
435
+ // shape. Every function and column looked at counts as examined, so a fix
436
+ // comes back from a re-check as fixed.
437
+ for (const fn of plan.anonFunctions || []) attempted.push('searchpath:' + fn.name);
438
+ for (const fn of plan.functions || []) attempted.push('conflict:' + fn.name);
439
+ for (const x of oneOfColumns(plan)) attempted.push('nullcheck:' + x.table + ':' + x.column.name);
440
+ // Only a table that has a rule for adding rows was looked at for this.
441
+ for (const name of new Set((plan.policies || []).filter((p) => /^(INSERT|ALL)$/i.test(String(p.cmd || 'ALL'))).map((p) => p.table_name))) {
442
+ attempted.push('noinsert:' + name);
443
+ }
444
+ const declared = searchPathsOf(plan).concat(refused, emptyChoicesOf(plan), noInsertsOf(plan));
445
+
446
+ // Storage buckets switched to public that also have a rule saying who may
447
+ // read them. Read from the bucket settings and the rules, never a file.
448
+ // Every bucket read counts as examined, so one made private comes back
449
+ // from a re-check as fixed.
450
+ for (const bucket of plan.buckets || []) attempted.push('bucket:' + bucket.name);
451
+ const buckets = bucketsOf(plan);
452
+
453
+ // Ties read from the original schema: the copy points auth.users keys at
454
+ // a stand-in, and would call every table untied. Owners read from the
455
+ // copy, the way seeding did.
456
+ const tagged = taggerFor(plan, theirs);
457
+
458
+ // Where this run was, so a re-check can tell a database it saw before from
459
+ // another one entirely (recheck.elsewhereOf). The name and address only.
460
+ const at = (await client.query(
461
+ 'SELECT current_database() AS database, host(inet_server_addr()) AS host, inet_server_port() AS port',
462
+ )).rows[0];
463
+
464
+ const everything = impersonation.findings.concat(writes.findings, ranked.findings, stranded.findings, raced,
465
+ privileged, declared, buckets, recursive);
466
+
467
+ return {
468
+ stopped: null,
469
+ // The app's functions read and never run - named under "What I did not test".
470
+ notCalled: notCalledOf(plan, everything),
471
+ where: {
472
+ database: at.database,
473
+ host: at.host || null,
474
+ port: at.port || null,
475
+ schema: sourceSchema,
476
+ tables: plan.tables.map((table) => table.name).sort(),
477
+ },
478
+ attacksRun: attempted.length,
479
+ notChecked: notChecked,
480
+ attempted: attempted,
481
+ findings: finding.describeAll(everything.map(tagged)),
482
+ };
483
+ } finally {
484
+ try {
485
+ await client.query('DROP SCHEMA IF EXISTS ' + schema.quote(copyName) + ' CASCADE');
486
+ say(' Copy deleted.');
487
+ } catch (err) {
488
+ line(' WARNING: the copy ' + copyName + ' could not be deleted: ' + err.message);
489
+ }
490
+ }
491
+ }
492
+
493
+ /**
494
+ * The exit code of a first run: 2 could not run, 1 something got through, 0
495
+ * every attack lost. Only confirmed findings count as "got through" - a
496
+ * function flagged for verification was never executed, and a script reading
497
+ * 1 would act on a break nobody demonstrated.
498
+ */
499
+ function exitCodeFor(result) {
500
+ if (result.stopped) return 2;
501
+ return (result.findings || []).some((f) => f.status !== 'verification required') ? 1 : 0;
502
+ }
503
+
504
+ /**
505
+ * The tables whose own rule reaches back into the same table through the
506
+ * helpers it calls - directly or through helpers those call. Such a rule has
507
+ * to be evaluated to be evaluated, which is how "stack depth limit exceeded"
508
+ * happens; the message itself does not say which table.
509
+ */
510
+ function loopingTables(plan) {
511
+ const escape = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
512
+ const calls = (text, name) => new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?\\s*\\(').test(String(text || ''));
513
+ const mentions = (text, name) => new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?([^A-Za-z0-9_$]|$)').test(String(text || ''));
514
+ const fns = (plan && plan.functions) || [];
515
+ const reached = (text) => {
516
+ const found = new Set(fns.filter((f) => calls(text, f.name)).map((f) => f.name));
517
+ let grew = true;
518
+ while (grew) {
519
+ grew = false;
520
+ for (const f of fns) {
521
+ if (found.has(f.name)) continue;
522
+ if (fns.some((g) => found.has(g.name) && calls(g.src, f.name))) { found.add(f.name); grew = true; }
523
+ }
524
+ }
525
+ return fns.filter((f) => found.has(f.name));
526
+ };
527
+ return ((plan && plan.tables) || []).map((t) => t.name).filter((name) => {
528
+ const rules = ((plan && plan.policies) || []).filter((p) => p.table_name === name)
529
+ .map((p) => String(p.qual || '') + ' ' + String(p.with_check || '')).join(' ');
530
+ return rules.trim() && reached(rules).some((f) => mentions(f.src, name));
531
+ });
532
+ }
533
+
534
+ /**
535
+ * Functions an anonymous visitor can call that run past the rules. Read from
536
+ * the shape, not attacked. Quiet: a yes/no about the caller, and a function
537
+ * that turns the caller away before it does anything (schema.guardedAtTheDoor).
538
+ */
539
+ function privilegedOf(plan) {
540
+ return ((plan && plan.anonFunctions) || []).filter((fn) => fn.callable && !fn.aboutCaller && !fn.guarded).map((fn) => ({
541
+ kind: 'privileged',
542
+ fn: fn.name,
543
+ table: fn.name,
544
+ args: fn.args,
545
+ writes: fn.writes,
546
+ hasFixedSearchPath: fn.hasFixedSearchPath,
547
+ // Its only check at the door is one a caller with no role slips past.
548
+ nullSlips: fn.nullSlips === true,
549
+ columns: [],
550
+ }));
551
+ }
552
+
553
+ /**
554
+ * Definer functions the app can call that leave their search_path open.
555
+ *
556
+ * Found on a blind test (Northstar): record_scan was SECURITY DEFINER,
557
+ * callable by every signed-in user, guarded at the door - and it said
558
+ * `insert into scans` with no schema and no search_path set. A name without a
559
+ * schema is looked up along whatever path the session has, so anybody able to
560
+ * put a table or function earlier on that path makes the function use theirs,
561
+ * with the owner's rights. Supabase's own advisor flags the same thing.
562
+ *
563
+ * Read, never run. A function already reported as privileged says this as
564
+ * part of that finding and is not reported twice.
565
+ */
566
+ function searchPathsOf(plan) {
567
+ const reported = new Set(privilegedOf(plan).map((f) => f.fn));
568
+ return ((plan && plan.anonFunctions) || [])
569
+ .filter((fn) => (fn.callable || fn.callableSignedIn) && !fn.hasFixedSearchPath && !reported.has(fn.name))
570
+ .map((fn) => ({
571
+ kind: 'searchpath',
572
+ fn: fn.name,
573
+ table: fn.name,
574
+ args: fn.args,
575
+ writes: fn.writes,
576
+ anon: fn.callable === true,
577
+ columns: [],
578
+ }));
579
+ }
580
+
581
+ // A CHECK that keeps one column to a fixed list, as Postgres writes it back:
582
+ // CHECK ((severity = ANY (ARRAY['critical'::text, 'high'::text])))
583
+ // CHECK (((status)::text = ANY ((ARRAY['a'::character varying])::text[])))
584
+ // Mirrored in engine.sql empty_choices.
585
+ const ONE_OF = /^CHECK \(+"?([A-Za-z_][\w$]*)"?\)?(?:::[\w ]+)? = ANY \(/;
586
+
587
+ /**
588
+ * Columns kept to a fixed list by a CHECK that still take an empty value.
589
+ *
590
+ * Found on a blind test (Northstar): findings.severity had
591
+ * CHECK (severity IN ('critical', 'high', 'medium', 'low')) and no NOT NULL.
592
+ * A CHECK refuses a row only when it can show the value wrong, and NULL is
593
+ * never shown wrong - so a finding with no severity at all was accepted, and
594
+ * the app's report showed a row with no label. Only said when nothing else
595
+ * fills the column: no default, not generated, and a CHECK that does not
596
+ * mention NULL itself (a CHECK that does has decided the question).
597
+ */
598
+ /**
599
+ * Tables nobody can add a row to: every rule for adding rows is an INSERT
600
+ * rule written with neither USING nor WITH CHECK, which Postgres reads as
601
+ * "no" - measured on PostgreSQL 18, a bare `create policy p on t for insert
602
+ * to authenticated;` refuses every insert. A rule for ALL, or one with a
603
+ * check, is a way in, and the table is left alone. Mirrors no_insert_findings
604
+ * in engine.sql.
605
+ *
606
+ * Found on blind round 15 (Northstar Ops on Supabase): tickets_insert was
607
+ * written that way, so create_ticket failed for every user - the app's main
608
+ * feature - and nothing in the report said so.
609
+ */
610
+ function noInsertsOf(plan) {
611
+ const byTable = new Map();
612
+ for (const p of (plan && plan.policies) || []) {
613
+ if (String(p.permissive || 'PERMISSIVE').toUpperCase() !== 'PERMISSIVE') continue;
614
+ const cmd = String(p.cmd || 'ALL').toUpperCase();
615
+ if (cmd !== 'INSERT' && cmd !== 'ALL') continue;
616
+ if (!byTable.has(p.table_name)) byTable.set(p.table_name, { bare: [], open: false });
617
+ const entry = byTable.get(p.table_name);
618
+ if (cmd === 'INSERT' && p.qual == null && p.with_check == null) entry.bare.push(p.name);
619
+ else entry.open = true;
620
+ }
621
+ return Array.from(byTable.keys()).sort()
622
+ .filter((table) => byTable.get(table).bare.length && !byTable.get(table).open)
623
+ .map((table) => ({ kind: 'noinsert', table: table, rules: byTable.get(table).bare.slice().sort(), columns: [] }));
624
+ }
625
+
626
+ function emptyChoicesOf(plan) {
627
+ return oneOfColumns(plan)
628
+ .filter((x) => !x.column.not_null && x.column.default_expr == null && !x.column.generated)
629
+ .map((x) => ({
630
+ kind: 'nullcheck',
631
+ table: x.table,
632
+ column: x.column.name,
633
+ allowed: Array.from(x.definition.matchAll(/'((?:[^']|'')*)'/g)).map((m) => m[1].split("''").join("'")),
634
+ constraint: x.constraint,
635
+ columns: [],
636
+ }));
637
+ }
638
+
639
+ /** Every column a CHECK keeps to a fixed list - each one looked at, empty or not. */
640
+ function oneOfColumns(plan) {
641
+ const found = [];
642
+ for (const table of (plan && plan.tables) || []) {
643
+ for (const rule of table.constraints || []) {
644
+ if (rule.kind !== 'c') continue;
645
+ const def = String(rule.definition || '');
646
+ if (/\b(or|null|and)\b/i.test(def)) continue;
647
+ const m = ONE_OF.exec(def);
648
+ if (!m) continue;
649
+ const column = (table.columns || []).find((c) => c.name === m[1]);
650
+ if (column) found.push({ table: table.name, column: column, constraint: rule.name, definition: def });
651
+ }
652
+ }
653
+ return found;
654
+ }
655
+
656
+ /**
657
+ * Every INSERT ... ON CONFLICT (...) in the app's functions, with the table it
658
+ * writes and the conflict target, read from the function's text. Mirrored in
659
+ * engine.sql conflict_targets, step for step: statements split on ";", the
660
+ * table after INSERT INTO, the columns inside ON CONFLICT ( ), and a WHERE
661
+ * right after them up to DO.
662
+ */
663
+ function conflictTargetsOf(plan) {
664
+ const tables = new Set(((plan && plan.tables) || []).map((t) => t.name));
665
+ const unquote = (part) => (part.startsWith('"') ? part.slice(1, -1) : part.toLowerCase());
666
+ const seen = new Set();
667
+ const out = [];
668
+ for (const fn of (plan && plan.functions) || []) {
669
+ const body = String(fn.src || '').replace(/--[^\n]*/g, ' ');
670
+ for (const statement of body.split(';')) {
671
+ const into = /(?:^|[^\w$])insert\s+into\s+((?:"[^"]+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"[^"]+"|[A-Za-z_][\w$]*))?)/i.exec(statement);
672
+ if (!into) continue;
673
+ const on = /(?:^|[^\w$])on\s+conflict\s*\(([^)]*)\)([\s\S]*)$/i.exec(statement);
674
+ if (!on || !on[1].trim()) continue;
675
+ const parts = into[1].match(/"[^"]+"|[A-Za-z_][\w$]*/g);
676
+ if (parts.length === 2 && unquote(parts[0]) !== plan.schema) continue;
677
+ const target = unquote(parts[parts.length - 1]);
678
+ if (!tables.has(target)) continue;
679
+ const where = /^\s*where\b/i.test(on[2]) ? on[2].split(/\bdo\b/i)[0].trim() : '';
680
+ const key = [fn.name, target, on[1].trim(), where].join('|');
681
+ if (seen.has(key)) continue;
682
+ seen.add(key);
683
+ out.push({ fn: fn.name, args: fn.args || '', target: target, on: on[1].trim(), where: where });
684
+ }
685
+ }
686
+ return out;
687
+ }
688
+
689
+ /**
690
+ * The WHERE of a partial unique index on exactly these columns, if there is
691
+ * one: what the ON CONFLICT has to repeat to be matched. Mirrored in
692
+ * engine.sql partial_where_for.
693
+ */
694
+ function partialWhereFor(indexes, table, on) {
695
+ const norm = (text) => String(text).replace(/["\s]/g, '').toLowerCase();
696
+ for (const index of indexes || []) {
697
+ if (index.table_name !== table) continue;
698
+ const m = /USING \w+ \(([^)]*)\)(?: WHERE (.*))?$/.exec(String(index.definition || ''));
699
+ if (m && m[2] && norm(m[1]) === norm(on)) return m[2];
700
+ }
701
+ return null;
702
+ }
703
+
704
+ /**
705
+ * The upserts Postgres refuses to run, asked on the copy.
706
+ *
707
+ * Found on a blind test (Northstar): upsert_project said
708
+ * ON CONFLICT (organization_id, external_ref), and the only unique index on
709
+ * those columns was partial (WHERE external_ref IS NOT NULL). Postgres can
710
+ * only use a partial index when the ON CONFLICT repeats its WHERE, so it
711
+ * refused the statement and the function failed on every call.
712
+ *
713
+ * Nothing of the app's is run. Each statement is put to the copy as
714
+ * EXPLAIN INSERT ... DEFAULT VALUES ON CONFLICT (...) DO NOTHING - planned,
715
+ * never executed - and only Postgres's own "no unique or exclusion constraint
716
+ * matching" (42P10) counts. Any other error says nothing either way.
717
+ */
718
+ async function conflictsIn(client, into, plan, indexes) {
719
+ const found = [];
720
+ for (const t of conflictTargetsOf(plan)) {
721
+ let refused = false;
722
+ await client.query('BEGIN');
723
+ try {
724
+ await client.query('EXPLAIN INSERT INTO ' + schema.quote(into) + '.' + schema.quote(t.target) +
725
+ ' DEFAULT VALUES ON CONFLICT (' + t.on + ')' + (t.where ? ' ' + t.where : '') + ' DO NOTHING');
726
+ } catch (err) {
727
+ refused = err.code === '42P10';
728
+ } finally {
729
+ await client.query('ROLLBACK').catch(() => {});
730
+ }
731
+ if (!refused) continue;
732
+ found.push({
733
+ kind: 'conflict',
734
+ fn: t.fn,
735
+ table: t.fn,
736
+ args: t.args,
737
+ target: t.target,
738
+ on: t.on,
739
+ partialWhere: partialWhereFor(indexes, t.target, t.on),
740
+ columns: [],
741
+ });
742
+ }
743
+ return found;
744
+ }
745
+
746
+ /**
747
+ * The tables whose rules decide who is asking from a setting the app's own
748
+ * server sets - current_setting('app.tenant_id') - rather than from a
749
+ * Supabase sign-in. Each is a table NOT checked, said with the settings.
750
+ *
751
+ * Found on blind round 13 (Northstar Ops, Express on plain Postgres): every
752
+ * rule read app.tenant_id / app.user_id, which nothing here sets. Every attack
753
+ * ran as nobody, every one was refused for that reason alone, and the report
754
+ * said "Nothing got through" over a table whose rule was USING (true) - the
755
+ * one sentence this program must never print by accident. A rule that also
756
+ * asks Supabase (auth.uid(), request.jwt...) is attacked as usual.
757
+ * Mirrors server_identity in engine.sql.
758
+ */
759
+ function serverIdentityOf(plan) {
760
+ const byTable = new Map();
761
+ for (const p of (plan && plan.policies) || []) {
762
+ const text = String(p.qual || '') + ' ' + String(p.with_check || '');
763
+ if (/\bauth\s*\.\s*(uid|jwt|role|email)\s*\(|request\.jwt/i.test(text)) continue;
764
+ const re = /current_setting\s*\(\s*'([^']+)'/gi;
765
+ let m;
766
+ while ((m = re.exec(text))) {
767
+ if (/^request\./i.test(m[1])) continue;
768
+ if (!byTable.has(p.table_name)) byTable.set(p.table_name, new Set());
769
+ byTable.get(p.table_name).add(m[1]);
770
+ }
771
+ }
772
+ return Array.from(byTable.keys()).sort().map((table) => {
773
+ const settings = Array.from(byTable.get(table)).sort();
774
+ // The why is short and per table; what it means is said once, under the
775
+ // list (notTestedLines), not repeated for every table.
776
+ return {
777
+ table: table,
778
+ why: 'its rules decide who is asking with ' + andList(settings.map((s) => "current_setting('" + s + "')")),
779
+ identity: true,
780
+ };
781
+ });
782
+ }
783
+
784
+ /**
785
+ * The app's functions that were read and never called: anything a visitor or
786
+ * a signed-in user can call, that no rule calls for them (those ran with every
787
+ * read), and that no finding already names. Said under "What I did not test".
788
+ *
789
+ * Found on a blind test (Northstar): the report on the repository said every
790
+ * attack ran, while upsert_project - broken on every call - and record_scan
791
+ * had never been run at all.
792
+ */
793
+ function notCalledOf(plan, findings) {
794
+ const escape = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
795
+ const rules = ((plan && plan.policies) || []).map((p) => String(p.qual || '') + ' ' + String(p.with_check || '')).join(' ');
796
+ const named = new Set((findings || []).map((f) => f.fn).filter(Boolean));
797
+ const names = ((plan && plan.functions) || [])
798
+ .filter((fn) => fn.client_callable === true && fn.from_extension !== true && !named.has(fn.name))
799
+ .map((fn) => fn.name)
800
+ .filter((name) => !new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?\\s*\\(').test(rules));
801
+ return Array.from(new Set(names)).sort();
802
+ }
803
+
804
+ /**
805
+ * A public bucket whose read rule says it was meant to be private.
806
+ *
807
+ * Found on two blind tests (OrbitDesk, AtlasPay): a bucket of exports and one
808
+ * of settlement evidence were switched to public. A public bucket hands any
809
+ * file to whoever has its link, and the rules on storage.objects are not asked.
810
+ * A rule that only lets in some people is how the app says the files are not
811
+ * for everyone - so a public bucket with one is worth a look. A rule that lets
812
+ * in everyone anyway (anon or public, and nothing but the bucket's name) says
813
+ * the opposite, and is not reported.
814
+ */
815
+ function bucketsOf(plan) {
816
+ const escape = (x) => String(x).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
817
+ const narrows = (rule, id) => {
818
+ const roles = schema.roleList(rule.roles);
819
+ const toEveryone = roles.includes('anon') || roles.includes('public');
820
+ const rest = String(rule.qual || '')
821
+ .replace(new RegExp("bucket_id\\s*=\\s*'" + escape(id) + "'(::text)?", 'gi'), '')
822
+ .replace(new RegExp("'" + escape(id) + "'(::text)?\\s*=\\s*bucket_id", 'gi'), '')
823
+ .replace(/\btrue\b|\band\b|[()\s]/gi, '');
824
+ return !toEveryone || rest !== '';
825
+ };
826
+ // A rule for putting files in or taking them out that only signed-in people
827
+ // pass AND that looks a file up in the app's own data (a subquery over a
828
+ // table: an attachments row, a membership). That is files the app treats as
829
+ // its members' records. A rule that only asks whose folder it is - the
830
+ // usual public avatars bucket - is not this.
831
+ const membersOnly = (rule) => {
832
+ const roles = schema.roleList(rule.roles);
833
+ const text = String(rule.qual || '') + ' ' + String(rule.with_check || '');
834
+ const open = roles.includes('anon') || roles.includes('public');
835
+ return (!open || /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(text)) && /\bfrom\s+/i.test(text);
836
+ };
837
+ return ((plan && plan.buckets) || []).filter((b) => b.public).map((b) => {
838
+ const writers = (b.writeRules || []).filter(membersOnly).map((rule) => rule.name);
839
+ return {
840
+ kind: 'bucket',
841
+ table: b.name,
842
+ bucket: b.id,
843
+ rules: (b.readRules || []).filter((rule) => narrows(rule, b.id)).map((rule) => rule.name),
844
+ // No read rule at all, and something else saying its files are not for
845
+ // everyone: a name that says what is in it (HarborLine: customer-exports,
846
+ // whose one read rule had failed to load), or rules that let only
847
+ // signed-in members put files in or take them out (blind round 15:
848
+ // workspace-files, written only through an attachments row of the
849
+ // uploader's, and readable by anyone with a link).
850
+ noRule: !(b.readRules || []).length && (PRIVATE_BUCKET.test(String(b.name)) || writers.length > 0),
851
+ writers: !(b.readRules || []).length ? writers : [],
852
+ columns: [],
853
+ };
854
+ }).filter((f) => f.rules.length || f.noRule);
855
+ }
856
+
857
+ // Names that say a bucket's files are somebody's own business. Whole words
858
+ // only: "plain" or "avatars" say nothing, and stay quiet.
859
+ const PRIVATE_BUCKET = /(^|[-_.])(exports?|private|documents?|docs|invoices?|contracts?|backups?|reports?|receipts?|statements?|payouts?|evidence|kyc|passports?|medical|records?|confidential|internal|secrets?|attachments?)([-_.]|$)/i;
860
+
861
+ /**
862
+ * What each finding needs to know about its table for its fix to be right:
863
+ * whether the app signs people in through Supabase, whether a row says whose
864
+ * it is, and which column that is. Without these a prompt falls back to "the
865
+ * column that says who each row belongs to" - found on a nightly read-back,
866
+ * whose findings came from the database without them.
867
+ */
868
+ function taggerFor(plan, tables) {
869
+ const ties = finding.authTiesOf(plan);
870
+ const owned = new Map((tables || []).map((table) => [table.name, attack.ownerColumn(table)]));
871
+ return (f) => Object.assign({}, f,
872
+ ties.has(f.table) ? { authTied: ties.get(f.table) } : {},
873
+ owned.has(f.table) ? { owned: Boolean(owned.get(f.table)) } : {},
874
+ owned.get(f.table) ? { ownedBy: owned.get(f.table) } : {});
875
+ }
876
+
877
+ /**
878
+ * What the nightly engine wrote down as not tried, in the shape a scan uses.
879
+ * It names a column's check as table and column apart, with no attack named,
880
+ * so "export_jobs.access_token could not be raced" was counted as a whole
881
+ * table not checked - and listed without its column.
882
+ */
883
+ function notCheckedFromNight(entries) {
884
+ return (entries || []).map((e) => {
885
+ if (!e || e.key || !e.column) return e;
886
+ const kind = /two requests at the same instant/.test(String(e.why)) ? 'duplicated' : 'orphaned';
887
+ return { table: e.table + '.' + e.column, key: kind + ':' + e.table + ':' + e.column, why: e.why };
888
+ });
889
+ }
890
+
891
+ /**
892
+ * A moment of the night, said in UTC - which is what the database runs on -
893
+ * and in this computer's own time, so 03:00 is not read as three at night
894
+ * here. Found on a real read-back: "the night of 2026-10-01 03:00" was 08:30
895
+ * in India, and nothing said so.
896
+ */
897
+ function bothTimes(iso) {
898
+ const at = new Date(iso);
899
+ return at.toISOString().slice(0, 16).replace('T', ' ') + ' UTC (' +
900
+ at.toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) + ' your time)';
901
+ }
902
+
903
+ /** "0 3 * * *" as a time of day in both, or the schedule as written. */
904
+ function scheduleTimes(cron) {
905
+ const m = /^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*$/.exec(String(cron || '').trim());
906
+ if (!m) return String(cron || 'whenever it is scheduled');
907
+ const now = new Date();
908
+ const at = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), Number(m[2]), Number(m[1])));
909
+ return 'every night at ' + at.toISOString().slice(11, 16) + ' UTC (' +
910
+ at.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) + ' your time)';
911
+ }
912
+
913
+ /** Where the nightly run's last answer is kept for the dashboard. */
914
+ function nightlyFile() {
915
+ return store.open(process.cwd()).nightNightly;
916
+ }
917
+
918
+ /**
919
+ * The nightly run's last answer, in the shape the dashboard reads: the same
920
+ * described findings a scan saves, and the three states that are not the same
921
+ * as "nothing found" - not installed, installed and never run, and stopped -
922
+ * said as themselves. `latest` is installer.latestRun's answer.
923
+ */
924
+ function nightlyRecord(latest, readAt, plan) {
925
+ const at = (value) => (value ? new Date(value).toISOString() : null);
926
+ if (!latest || !latest.installed) return { readAt: readAt, installed: false };
927
+ const where = latest.where || {};
928
+ const record = {
929
+ readAt: readAt,
930
+ installed: true,
931
+ scheduled: where.job ? where.job.schedule : null,
932
+ active: Boolean(where.job && where.job.active),
933
+ source: where.source || null,
934
+ ranAt: null,
935
+ };
936
+ const run = latest.run;
937
+ if (!run) return record;
938
+ // The night's findings, told as the night's: tagged with what their table
939
+ // needs for a right fix when the schema could be read, and joined by the
940
+ // functions anyone can call and the public buckets - read now, when this is
941
+ // asked, which is newer than the night. The night records them too since
942
+ // 0.1.32 (for the dashboard), so where they can be read now the night's own
943
+ // are left out rather than said twice; where they cannot, the night's stand.
944
+ const tag = plan ? taggerFor(plan, plan.tables) : (f) => f;
945
+ // A loop the engine could not name ("stack depth limit exceeded") is put
946
+ // on the tables whose own rule reads back into them, read from the shape.
947
+ const loops = plan ? loopingTables(plan) : [];
948
+ const night = [];
949
+ for (const f of run.findings || []) {
950
+ if (plan && (f.kind === 'privileged' || f.kind === 'bucket')) continue;
951
+ const at = f.kind === 'recursive' && !f.table ? (loops.length ? loops : ['one of your tables']) : [f.table];
952
+ for (const table of at) night.push(tag(Object.assign({}, f, { table: table, fromNight: true })));
953
+ }
954
+ const now = plan ? privilegedOf(plan).concat(bucketsOf(plan)).map(tag) : [];
955
+ return Object.assign(record, {
956
+ ranAt: at(run.ran_at),
957
+ source: run.source,
958
+ stopped: run.stopped || null,
959
+ attacksRun: run.attacks_run || 0,
960
+ notChecked: notCheckedFromNight(run.not_checked),
961
+ notCalled: run.not_called || [],
962
+ findings: run.stopped ? [] : finding.describeAll(night.concat(now)),
963
+ functionsReadAt: plan ? readAt : null,
964
+ });
965
+ }
966
+
967
+ /** Kept for the dashboard. A failure here never touches the run itself. */
968
+ function saveNightly(file, record) {
969
+ try {
970
+ fs.writeFileSync(file, JSON.stringify(record, null, 2));
971
+ return true;
972
+ } catch (err) {
973
+ return false;
974
+ }
975
+ }
976
+
977
+ /** The last run, or null if there has not been one worth keeping. */
978
+ function loadLastRun(file) {
979
+ try {
980
+ const saved = JSON.parse(fs.readFileSync(file, 'utf8'));
981
+ return saved && Array.isArray(saved.findings) ? saved : null;
982
+ } catch (err) {
983
+ return null;
984
+ }
985
+ }
986
+
987
+ /**
988
+ * Keeps a run so the next one can be measured against it.
989
+ *
990
+ * A run that stopped is never saved. Overwriting a real list of problems with
991
+ * an empty one from a run that fell over would quietly lose every finding, and
992
+ * the re-check after that would have nothing to hold anybody to.
993
+ */
994
+ function saveRun(file, result) {
995
+ if (result.stopped) return;
996
+ try {
997
+ fs.writeFileSync(file, JSON.stringify(result, null, 2));
998
+ } catch (err) {
999
+ line(' WARNING: I could not save this run to ' + file + ', so tomorrow I');
1000
+ line(' will have nothing to compare against: ' + err.message);
1001
+ }
1002
+ }
1003
+
1004
+ /**
1005
+ * The last thing on the screen: what was not tested, and why.
1006
+ *
1007
+ * A report that ends on "nothing got through" is read as "I am safe", and the
1008
+ * distance between those two sentences is the whole product. So the last
1009
+ * words are always the limits of the answer, not the answer.
1010
+ *
1011
+ * It is printed even when nothing was missed, because "I tested all of it"
1012
+ * and "I did not tell you what I skipped" look identical when the section is
1013
+ * simply absent, and only one of them is good news.
1014
+ */
1015
+ function notTestedLines(result) {
1016
+ const notChecked = result.notChecked || [];
1017
+ const lines = ['', ' ' + '-'.repeat(68), ' What I did not test', ''];
1018
+
1019
+ if (!notChecked.length) {
1020
+ lines.push(' Every table I could reach was attacked, and every attack I have');
1021
+ lines.push(' finished, ran.');
1022
+ } else {
1023
+ // Wrapped: a reason can be a sentence or two (serverIdentityOf), and an
1024
+ // unwrapped one ran off the side of the terminal and the dashboard.
1025
+ for (const missed of notChecked) {
1026
+ wrap(missed.table + ' - ' + missed.why, 66).forEach((l, i) => lines.push((i ? ' ' : ' ') + l));
1027
+ }
1028
+ if (notChecked.some((missed) => missed.identity)) {
1029
+ lines.push('');
1030
+ wrap('Those settings are set by your own server, not by a Supabase sign-in. I sign in the way Supabase ' +
1031
+ 'does, so every attack on those tables ran as nobody, and being refused proves nothing about them. ' +
1032
+ 'Check those rules by hand, or test them with your server setting who is asking.', 66)
1033
+ .forEach((l) => lines.push(' ' + l));
1034
+ }
1035
+ lines.push('');
1036
+ lines.push(' These are unknown, not clear. An attack that never ran comes back');
1037
+ lines.push(' looking exactly like an attack that was refused.');
1038
+ }
1039
+
1040
+ // Found on a blind test (Northstar): "every attack ran" was printed over an
1041
+ // upsert that failed on every call, because no attack calls the app's own
1042
+ // functions. They are named, so the sentence above is read as being about
1043
+ // tables only.
1044
+ const notCalled = result.notCalled || [];
1045
+ if (notCalled.length) {
1046
+ lines.push('');
1047
+ wrap('Your app\'s ' + (notCalled.length === 1 ? 'function ' : 'functions ') +
1048
+ andList(notCalled.map((name) => name + '()')) + (notCalled.length === 1 ? ' was' : ' were') +
1049
+ ' read, never called. Whether ' + (notCalled.length === 1 ? 'it works' : 'they work') +
1050
+ ', and what ' + (notCalled.length === 1 ? 'it lets' : 'they let') + ' a caller do, is not tested here.', 68)
1051
+ .forEach((l) => lines.push(' ' + l));
1052
+ }
1053
+
1054
+ // Said every time, whether or not the collision attack ran. It is the one
1055
+ // limit that is structural rather than circumstantial, and a person
1056
+ // deciding how far to trust this deserves to know it is there.
1057
+ lines.push('');
1058
+ lines.push(' One thing is never reported, on purpose: the lost update - two');
1059
+ lines.push(' withdrawals of 100 from a balance of 100 that both go through.');
1060
+ lines.push(' Every Postgres database behaves that way unless the app asks it');
1061
+ lines.push(' not to, so whether yours is affected depends on code I never see.');
1062
+ lines.push(' Flagging it would mean flagging every app with a number in it.');
1063
+ lines.push('');
1064
+
1065
+ return lines;
1066
+ }
1067
+
1068
+ /**
1069
+ * The warning above the verdict: how much went untested, counted honestly.
1070
+ *
1071
+ * Every entry used to be counted as a table. Found on a benchmark: one
1072
+ * column's duplicate test did not run, and the top line said "1 table was
1073
+ * NOT checked" about a table every other attack had covered - and a table
1074
+ * with two attacks stuck was counted as two tables. An entry with no attack
1075
+ * named is a whole table nothing could be put into; one with an attack named
1076
+ * is part of a table.
1077
+ */
1078
+ function missedLines(notChecked) {
1079
+ const tableOf = (entry) => String(entry.table).split('.')[0];
1080
+ const whole = new Set(notChecked.filter((entry) => !entry.key).map(tableOf));
1081
+ const parts = notChecked.filter((entry) => entry.key && !whole.has(tableOf(entry)));
1082
+ const partly = new Set(parts.map(tableOf));
1083
+ const tables = (n) => n + (n === 1 ? ' table' : ' tables');
1084
+ const said = [];
1085
+ if (whole.size) said.push(tables(whole.size) + (whole.size === 1 ? ' was' : ' were') + ' NOT checked');
1086
+ if (parts.length) {
1087
+ said.push(parts.length + (parts.length === 1 ? ' check' : ' checks') + ' on ' +
1088
+ (whole.size ? tables(partly.size).replace(/ table/, ' other table') : tables(partly.size)) +
1089
+ (parts.length === 1 ? ' was' : ' were') + ' NOT run');
1090
+ }
1091
+ const it = notChecked.length === 1 ? 'it' : 'them';
1092
+ return wrap(said.join(', and ') + '. Whatever this report says next is not about ' + it + '. Treat ' +
1093
+ it + ' as unknown, not as clear - the list, and why, is at the end.', 68).map((l) => ' ' + l);
1094
+ }
1095
+
1096
+ /** The report, as the person reads it in the morning. */
1097
+ function report(result) {
1098
+ if (result.stopped) {
1099
+ line('');
1100
+ line(' I could not check this app.');
1101
+ line('');
1102
+ line(' ' + result.stopped);
1103
+ line('');
1104
+ return;
1105
+ }
1106
+
1107
+ // Said before anything else, and said even on a clean night. "Everything
1108
+ // held" is only true about the tables that were actually tried, and a table
1109
+ // no row could be put into looks exactly like a table nothing got out of.
1110
+ const notChecked = result.notChecked || [];
1111
+ // Said before the verdict, and again in full at the very end. The warning
1112
+ // has to come first or "everything held" is read before the reason it might
1113
+ // not mean anything; the list has to come last or it is the first thing
1114
+ // scrolled past. The two are not the same sentence twice - this one is
1115
+ // whether to trust the verdict, and the one at the end is what to go and
1116
+ // look at.
1117
+ const sayWhatWasMissed = () => {
1118
+ if (!notChecked.length) return;
1119
+ line('');
1120
+ missedLines(notChecked).forEach(line);
1121
+ };
1122
+
1123
+ if (!result.findings.length) {
1124
+ if (notChecked.length) {
1125
+ sayWhatWasMissed();
1126
+ line('');
1127
+ line(' Everything I could try, held.');
1128
+ line('');
1129
+ } else {
1130
+ finding.allClearLines(result.attacksRun).forEach(line);
1131
+ }
1132
+ notTestedLines(result).forEach(line);
1133
+ return;
1134
+ }
1135
+
1136
+ sayWhatWasMissed();
1137
+
1138
+ // Confirmed breaks and things that only need a look are not the same claim,
1139
+ // so they are counted apart. Saying "3 problems" when one of them was never
1140
+ // executed is the kind of overstatement that costs the next report its trust.
1141
+ const confirmed = result.findings.filter((f) => f.status !== 'verification required');
1142
+ const toVerify = result.findings.filter((f) => f.status === 'verification required');
1143
+ line('');
1144
+ if (confirmed.length) {
1145
+ line(' ' + confirmed.length + (confirmed.length === 1 ? ' problem' : ' problems') + ' found.');
1146
+ }
1147
+ if (toVerify.length) {
1148
+ // A viewer who can edit was really tried; a function or a bucket was only
1149
+ // read. Either way it is the owner's call, and the line says which.
1150
+ const tried = toVerify.some((f) => f.kind === 'role' || f.kind === 'teamread');
1151
+ const spotted = toVerify.some((f) => f.kind !== 'role' && f.kind !== 'teamread');
1152
+ const it = toVerify.length === 1 ? 'it' : 'them';
1153
+ line(' ' + toVerify.length + ' thing' + (toVerify.length === 1 ? '' : 's') + ' to check - ' +
1154
+ (spotted && !tried ? 'I did not attack ' + it + ', only spotted the risk.'
1155
+ : 'whether ' + (toVerify.length === 1 ? 'it is a problem' : 'each is a problem') + ' is your decision.'));
1156
+ }
1157
+
1158
+ for (const item of result.findings) {
1159
+ line('');
1160
+ line(' ' + '-'.repeat(68));
1161
+ line(' ' + item.severity + ' ' + item.table +
1162
+ (item.status === 'verification required' ? ' (verification required)' : ''));
1163
+ line('');
1164
+ line(' ' + item.headline);
1165
+ line('');
1166
+ for (const paragraph of [item.body, item.cause].filter(Boolean)) {
1167
+ wrap(paragraph, 70).forEach((l) => line(' ' + l));
1168
+ line('');
1169
+ }
1170
+ line(' Paste this into Lovable, Claude or Cursor:');
1171
+ line('');
1172
+ item.fixPrompt.split('\n').forEach((l) => line(' ' + l));
1173
+ }
1174
+
1175
+ line('');
1176
+ line(' ' + '-'.repeat(68));
1177
+ line(' Nothing here touched your live app. I built a copy, attacked the copy,');
1178
+ line(' and deleted it. Not one real customer was involved.');
1179
+
1180
+ notTestedLines(result).forEach(line);
1181
+ }
1182
+
1183
+ /** Wraps at a width a person can read without their eyes sliding off. */
1184
+ /** a, b and c */
1185
+ function andList(items) {
1186
+ return items.length < 2 ? items.join('') : items.slice(0, -1).join(', ') + ' and ' + items[items.length - 1];
1187
+ }
1188
+
1189
+ function wrap(text, width) {
1190
+ const words = String(text).split(/\s+/);
1191
+ const lines = [];
1192
+ let current = '';
1193
+ for (const word of words) {
1194
+ if ((current + ' ' + word).trim().length > width) {
1195
+ lines.push(current.trim());
1196
+ current = word;
1197
+ } else {
1198
+ current = (current + ' ' + word).trim();
1199
+ }
1200
+ }
1201
+ if (current) lines.push(current.trim());
1202
+ return lines;
1203
+ }
1204
+
1205
+ async function main() {
1206
+ const args = process.argv.slice(2).filter((arg) => arg !== '--recheck');
1207
+ const again = process.argv.includes('--recheck');
1208
+
1209
+ // The connection string is the key to the customer's whole database, and
1210
+ // typed on the command line it goes into shell history and into the process
1211
+ // list where anybody on the machine can read it. The environment variable is
1212
+ // the way it should be given; the argument still works because taking it
1213
+ // away would break anyone already using it.
1214
+ const fromEnvironment = process.env.KN_DATABASE_URL;
1215
+ const connection = args.length > 1 ? args[0] : fromEnvironment;
1216
+ const target = args.length > 1 ? args[1] : args[0];
1217
+
1218
+ if (!connection || !target) {
1219
+ console.error('');
1220
+ console.error(' KN_DATABASE_URL=postgresql://... node scan.js <schema> [--recheck]');
1221
+ console.error(' node scan.js "<connection string>" <schema> [--recheck]');
1222
+ console.error('');
1223
+ console.error(' <schema> is usually "public".');
1224
+ console.error('');
1225
+ process.exit(2);
1226
+ }
1227
+
1228
+ let file;
1229
+ try {
1230
+ file = lastRunFile(line);
1231
+ } catch (err) {
1232
+ // 2, not 1: nothing was attacked, so nothing "got through".
1233
+ console.error('');
1234
+ console.error(' There is nowhere to keep this run (' + err.message + ').');
1235
+ console.error(' Set KRYPTHEON_HOME to a folder you can write to, and run this again.');
1236
+ console.error('');
1237
+ process.exit(2);
1238
+ }
1239
+ const before = again ? loadLastRun(file) : null;
1240
+ if (again && !before) {
1241
+ // Running a fresh scan and calling it a re-check would report every
1242
+ // problem as new and confirm nothing, which reads like an answer.
1243
+ console.error('');
1244
+ console.error(' There is no earlier run here to compare against.');
1245
+ console.error('');
1246
+ console.error(' Run it without --recheck first, fix what it finds, then come back.');
1247
+ console.error('');
1248
+ process.exit(2);
1249
+ }
1250
+
1251
+ const client = new Client({ connectionString: connection });
1252
+ await client.connect();
1253
+ try {
1254
+ const result = await scan(client, target, {
1255
+ // Two requests arriving together cannot be faked down one connection, so
1256
+ // the collision attack is handed a way to open its own.
1257
+ openSession: async () => {
1258
+ const extra = new Client({ connectionString: connection });
1259
+ await extra.connect();
1260
+ return extra;
1261
+ },
1262
+ });
1263
+
1264
+ if (!before) {
1265
+ report(result);
1266
+ saveRun(file, result);
1267
+ // Three answers, three codes: 0 clean, 1 problems found, 2 could not
1268
+ // run. A scan that stopped used to exit 0 with no findings, which is
1269
+ // what any script watching it would read as a pass.
1270
+ process.exitCode = exitCodeFor(result);
1271
+ return;
1272
+ }
1273
+
1274
+ const verdict = recheck.compare(before, result);
1275
+ recheck.describe(verdict).forEach(line);
1276
+ recheck.badgeLines(verdict, result.attacksRun || 0).forEach(line);
1277
+ // The verdict says what changed; this says what to do about what did not.
1278
+ if (result.findings.length) report(result);
1279
+ else notTestedLines(result).forEach(line);
1280
+ saveRun(file, result);
1281
+ // Two different databases: the verdict is a comparison, so the exit code
1282
+ // is this run's own answer, the same as a first run.
1283
+ process.exitCode = result.stopped ? 2 : verdict.elsewhere ? exitCodeFor(result) : verdict.allClear ? 0 : 1;
1284
+ } finally {
1285
+ await client.end();
1286
+ }
1287
+ }
1288
+
1289
+ if (require.main === module) {
1290
+ main().catch((err) => {
1291
+ console.error('');
1292
+ console.error(' The scan could not run: ' + err.message);
1293
+ console.error('');
1294
+ process.exit(1);
1295
+ });
1296
+ }
1297
+
1298
+ module.exports = {
1299
+ pickSchema: pickSchema,
1300
+ scan: scan,
1301
+ report: report,
1302
+ missedLines: missedLines,
1303
+ wrap: wrap,
1304
+ loadLastRun: loadLastRun,
1305
+ saveRun: saveRun,
1306
+ nightlyFile: nightlyFile,
1307
+ nightlyRecord: nightlyRecord,
1308
+ bothTimes: bothTimes,
1309
+ scheduleTimes: scheduleTimes,
1310
+ notCheckedFromNight: notCheckedFromNight,
1311
+ privilegedOf: privilegedOf,
1312
+ searchPathsOf: searchPathsOf,
1313
+ emptyChoicesOf: emptyChoicesOf,
1314
+ noInsertsOf: noInsertsOf,
1315
+ oneOfColumns: oneOfColumns,
1316
+ conflictTargetsOf: conflictTargetsOf,
1317
+ partialWhereFor: partialWhereFor,
1318
+ conflictsIn: conflictsIn,
1319
+ notCalledOf: notCalledOf,
1320
+ serverIdentityOf: serverIdentityOf,
1321
+ bucketsOf: bucketsOf,
1322
+ loopingTables: loopingTables,
1323
+ taggerFor: taggerFor,
1324
+ saveNightly: saveNightly,
1325
+ sweepOldCopies: sweepOldCopies,
1326
+ notTestedLines: notTestedLines,
1327
+ lastRunFile: lastRunFile,
1328
+ exitCodeFor: exitCodeFor,
1329
+ ABANDONED_AFTER: ABANDONED_AFTER,
1330
+ };