kryptheon-night 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/attack.js +46 -0
- package/bin/kryptheon-night.js +4 -1
- package/engine.sql +46 -1
- package/finding.js +344 -42
- package/package.json +4 -2
- package/recheck.js +45 -4
- package/scan.js +69 -6
- package/schema.js +1210 -1137
- package/store.js +15 -0
- package/tamper.js +20 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kryptheon-night",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Attacks a copy of your Supabase database and tells you in plain English what got in.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"node": ">=18.0.0"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
-
"check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node loop.check.js",
|
|
34
|
+
"check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node cli.check.js && node loop.check.js",
|
|
35
35
|
"check:dry": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node recheck.check.js && node outside.check.js",
|
|
36
36
|
"check:package": "node package.check.js",
|
|
37
37
|
"check:store": "node store.check.js",
|
|
@@ -52,6 +52,8 @@
|
|
|
52
52
|
"check:shapes": "node shapes.check.js",
|
|
53
53
|
"check:verdicts": "node verdicts.check.js",
|
|
54
54
|
"check:shop": "node shop.check.js",
|
|
55
|
+
"check:privileged": "node privileged.check.js",
|
|
56
|
+
"check:cli": "node cli.check.js",
|
|
55
57
|
"check:writes": "node tamper.check.js",
|
|
56
58
|
"check:orphans": "node orphans.check.js",
|
|
57
59
|
"probe:write": "node probe-write.js",
|
package/recheck.js
CHANGED
|
@@ -42,9 +42,20 @@ function keyOf(item) {
|
|
|
42
42
|
* untestable table would land in `fixed`, and a green badge would be handed to
|
|
43
43
|
* an app nobody checked.
|
|
44
44
|
*/
|
|
45
|
+
/**
|
|
46
|
+
* A report shows one finding per table, but each one keeps the proofs it was
|
|
47
|
+
* made of. Those are what get compared: closing a table to strangers' writes
|
|
48
|
+
* while it stays readable is one proof fixed and one still open, not "the
|
|
49
|
+
* table's finding" either fixed or not. Reports saved before findings were
|
|
50
|
+
* grouped have no members, and are read as they are.
|
|
51
|
+
*/
|
|
52
|
+
function proofsOf(list) {
|
|
53
|
+
return (list || []).reduce((all, item) => all.concat(item.members && item.members.length ? item.members : [item]), []);
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
function compare(before, after) {
|
|
46
|
-
const was = (before && before.findings)
|
|
47
|
-
const now = (after && after.findings)
|
|
57
|
+
const was = proofsOf(before && before.findings);
|
|
58
|
+
const now = proofsOf(after && after.findings);
|
|
48
59
|
|
|
49
60
|
// The second run failed outright. Nothing can be claimed about anything.
|
|
50
61
|
if (after && after.stopped) {
|
|
@@ -116,6 +127,9 @@ function compare(before, after) {
|
|
|
116
127
|
stillOpen: stillOpen,
|
|
117
128
|
unverifiable: unverifiable,
|
|
118
129
|
newlyBroken: newlyBroken,
|
|
130
|
+
// Carried so the re-check can say why there is no green light when the
|
|
131
|
+
// only thing standing in the way is what could not be tested.
|
|
132
|
+
untested: skipped,
|
|
119
133
|
// Green is the strictest thing this program says, so it is the hardest to
|
|
120
134
|
// earn: everything that was wrong is now provably right, nothing new broke,
|
|
121
135
|
// and nothing at all was left untested.
|
|
@@ -146,6 +160,21 @@ function badgeLines(result, attacksRun) {
|
|
|
146
160
|
];
|
|
147
161
|
}
|
|
148
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Which proof a line is about. One table can have several - strangers reading
|
|
165
|
+
* it, strangers writing to it - and "waitlist, waitlist" under "fixed" would
|
|
166
|
+
* not say which one was closed.
|
|
167
|
+
*/
|
|
168
|
+
function whatOf(item) {
|
|
169
|
+
if (item.kind === 'exposed') return 'read by anyone';
|
|
170
|
+
if (item.kind === 'crossed') return "customers reading each other's rows";
|
|
171
|
+
if (item.kind === 'writable') return item.who === 'anyone' ? 'written by anyone' : 'written by any signed-in customer';
|
|
172
|
+
if (item.kind === 'duplicated') return 'the same ' + (item.column || 'value') + ' twice';
|
|
173
|
+
if (item.kind === 'orphaned') return 'rows left pointing at nothing';
|
|
174
|
+
if (item.kind === 'privileged') return 'a function anyone can call';
|
|
175
|
+
return item.kind;
|
|
176
|
+
}
|
|
177
|
+
|
|
149
178
|
/** What the re-check says, in the order it matters. */
|
|
150
179
|
function describe(result) {
|
|
151
180
|
const lines = [''];
|
|
@@ -162,7 +191,7 @@ function describe(result) {
|
|
|
162
191
|
lines.push(' ' + result.fixed.length + (result.fixed.length === 1 ? ' problem is' : ' problems are') + ' fixed:');
|
|
163
192
|
lines.push('');
|
|
164
193
|
for (const item of result.fixed) {
|
|
165
|
-
lines.push(' ' + item.table + ' - I ran the same attack again and it was refused.');
|
|
194
|
+
lines.push(' ' + item.table + ' (' + whatOf(item) + ') - I ran the same attack again and it was refused.');
|
|
166
195
|
}
|
|
167
196
|
lines.push('');
|
|
168
197
|
}
|
|
@@ -182,7 +211,7 @@ function describe(result) {
|
|
|
182
211
|
lines.push(' ' + result.unverifiable.length + ' I could NOT confirm:');
|
|
183
212
|
lines.push('');
|
|
184
213
|
for (const item of result.unverifiable) {
|
|
185
|
-
lines.push(' ' + item.table + ' - ' + item.why);
|
|
214
|
+
lines.push(' ' + item.table + ' (' + whatOf(item) + ') - ' + item.why);
|
|
186
215
|
}
|
|
187
216
|
lines.push('');
|
|
188
217
|
lines.push(' These are not fixed and not broken - they are unknown. The problem');
|
|
@@ -208,6 +237,18 @@ function describe(result) {
|
|
|
208
237
|
lines.push('');
|
|
209
238
|
}
|
|
210
239
|
|
|
240
|
+
// Found on a real app: "1 problem is fixed", nothing else, and a non-zero
|
|
241
|
+
// exit with no word about why. The reason was parts of the app that could
|
|
242
|
+
// not be tested - which has to be said, or the exit code reads as a bug.
|
|
243
|
+
const untested = result.untested || [];
|
|
244
|
+
if (untested.length) {
|
|
245
|
+
lines.push(' No green light yet: ' + untested.length + ' ' +
|
|
246
|
+
(untested.length === 1 ? 'part of your app was' : 'parts of your app were') +
|
|
247
|
+
' not tested this time, so I cannot');
|
|
248
|
+
lines.push(' call it clear. They are listed at the end, with the reason for each.');
|
|
249
|
+
lines.push('');
|
|
250
|
+
}
|
|
251
|
+
|
|
211
252
|
return lines;
|
|
212
253
|
}
|
|
213
254
|
|
package/scan.js
CHANGED
|
@@ -344,13 +344,52 @@ async function scan(client, sourceSchema, options) {
|
|
|
344
344
|
}
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
+
// Where people actually sign in decides how bad a duplicate email is. On
|
|
348
|
+
// Supabase that is auth.users, which keeps emails unique; a public table's
|
|
349
|
+
// email column is then a copy. Only read, never written.
|
|
350
|
+
const loginElsewhere = (await client.query("select to_regclass('auth.users') is not null as found")).rows[0].found;
|
|
351
|
+
const raced = collisions.findings.map((f) =>
|
|
352
|
+
f.expectation === 'identity' ? Object.assign({}, f, { loginElsewhere: loginElsewhere }) : f,
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
// Functions an anonymous visitor can call that run past the rules. Read
|
|
356
|
+
// from the shape, not attacked: nothing is executed, because whether such
|
|
357
|
+
// a function is meant to be public is a question only the owner answers.
|
|
358
|
+
//
|
|
359
|
+
// Every definer function read this run counts as examined, whether or not
|
|
360
|
+
// anon can call it. That is what lets the re-check call a revoked grant
|
|
361
|
+
// fixed: the function was looked at again and the reach was gone. The first
|
|
362
|
+
// version recorded only the flagged ones, so a revoke made the function
|
|
363
|
+
// vanish from both lists and the fix came back as "could not confirm".
|
|
364
|
+
for (const fn of plan.anonFunctions || []) attempted.push('privileged:' + fn.name);
|
|
365
|
+
const privileged = (plan.anonFunctions || []).filter((fn) => fn.callable).map((fn) => ({
|
|
366
|
+
kind: 'privileged',
|
|
367
|
+
fn: fn.name,
|
|
368
|
+
table: fn.name,
|
|
369
|
+
args: fn.args,
|
|
370
|
+
writes: fn.writes,
|
|
371
|
+
hasFixedSearchPath: fn.hasFixedSearchPath,
|
|
372
|
+
columns: [],
|
|
373
|
+
}));
|
|
374
|
+
|
|
375
|
+
// Whether each table is tied to Supabase's sign-in decides what its fix
|
|
376
|
+
// can be. Read from the original schema: the copy points auth.users keys
|
|
377
|
+
// at a stand-in, and would call every table untied.
|
|
378
|
+
const ties = finding.authTiesOf(plan);
|
|
379
|
+
// And whether a row says whose it is: with no such column, no rule can
|
|
380
|
+
// tie it to the person asking. Read from the copy, the way seeding did.
|
|
381
|
+
const owned = new Map(theirs.map((table) => [table.name, Boolean(attack.ownerColumn(table))]));
|
|
382
|
+
const tagged = (f) => Object.assign({}, f,
|
|
383
|
+
ties.has(f.table) ? { authTied: ties.get(f.table) } : {},
|
|
384
|
+
owned.has(f.table) ? { owned: owned.get(f.table) } : {});
|
|
385
|
+
|
|
347
386
|
return {
|
|
348
387
|
stopped: null,
|
|
349
388
|
attacksRun: attempted.length,
|
|
350
389
|
notChecked: notChecked,
|
|
351
390
|
attempted: attempted,
|
|
352
391
|
findings: finding.describeAll(
|
|
353
|
-
impersonation.findings.concat(writes.findings, stranded.findings,
|
|
392
|
+
impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged).map(tagged),
|
|
354
393
|
),
|
|
355
394
|
};
|
|
356
395
|
} finally {
|
|
@@ -363,6 +402,17 @@ async function scan(client, sourceSchema, options) {
|
|
|
363
402
|
}
|
|
364
403
|
}
|
|
365
404
|
|
|
405
|
+
/**
|
|
406
|
+
* The exit code of a first run: 2 could not run, 1 something got through, 0
|
|
407
|
+
* every attack lost. Only confirmed findings count as "got through" - a
|
|
408
|
+
* function flagged for verification was never executed, and a script reading
|
|
409
|
+
* 1 would act on a break nobody demonstrated.
|
|
410
|
+
*/
|
|
411
|
+
function exitCodeFor(result) {
|
|
412
|
+
if (result.stopped) return 2;
|
|
413
|
+
return (result.findings || []).some((f) => f.status !== 'verification required') ? 1 : 0;
|
|
414
|
+
}
|
|
415
|
+
|
|
366
416
|
/** The last run, or null if there has not been one worth keeping. */
|
|
367
417
|
function loadLastRun(file) {
|
|
368
418
|
try {
|
|
@@ -477,18 +527,29 @@ function report(result) {
|
|
|
477
527
|
|
|
478
528
|
sayWhatWasMissed();
|
|
479
529
|
|
|
480
|
-
|
|
530
|
+
// Confirmed breaks and things that only need a look are not the same claim,
|
|
531
|
+
// so they are counted apart. Saying "3 problems" when one of them was never
|
|
532
|
+
// executed is the kind of overstatement that costs the next report its trust.
|
|
533
|
+
const confirmed = result.findings.filter((f) => f.status !== 'verification required');
|
|
534
|
+
const toVerify = result.findings.filter((f) => f.status === 'verification required');
|
|
481
535
|
line('');
|
|
482
|
-
|
|
536
|
+
if (confirmed.length) {
|
|
537
|
+
line(' ' + confirmed.length + (confirmed.length === 1 ? ' problem' : ' problems') + ' found.');
|
|
538
|
+
}
|
|
539
|
+
if (toVerify.length) {
|
|
540
|
+
line(' ' + toVerify.length + ' thing' + (toVerify.length === 1 ? '' : 's') +
|
|
541
|
+
' to check - I did not attack ' + (toVerify.length === 1 ? 'it' : 'them') + ', only spotted the risk.');
|
|
542
|
+
}
|
|
483
543
|
|
|
484
544
|
for (const item of result.findings) {
|
|
485
545
|
line('');
|
|
486
546
|
line(' ' + '-'.repeat(68));
|
|
487
|
-
line(' ' + item.severity + ' ' + item.table
|
|
547
|
+
line(' ' + item.severity + ' ' + item.table +
|
|
548
|
+
(item.status === 'verification required' ? ' (verification required)' : ''));
|
|
488
549
|
line('');
|
|
489
550
|
line(' ' + item.headline);
|
|
490
551
|
line('');
|
|
491
|
-
for (const paragraph of [item.body, item.cause]) {
|
|
552
|
+
for (const paragraph of [item.body, item.cause].filter(Boolean)) {
|
|
492
553
|
wrap(paragraph, 70).forEach((l) => line(' ' + l));
|
|
493
554
|
line('');
|
|
494
555
|
}
|
|
@@ -587,7 +648,7 @@ async function main() {
|
|
|
587
648
|
// Three answers, three codes: 0 clean, 1 problems found, 2 could not
|
|
588
649
|
// run. A scan that stopped used to exit 0 with no findings, which is
|
|
589
650
|
// what any script watching it would read as a pass.
|
|
590
|
-
process.exitCode = result
|
|
651
|
+
process.exitCode = exitCodeFor(result);
|
|
591
652
|
return;
|
|
592
653
|
}
|
|
593
654
|
|
|
@@ -596,6 +657,7 @@ async function main() {
|
|
|
596
657
|
recheck.badgeLines(verdict, result.attacksRun || 0).forEach(line);
|
|
597
658
|
// The verdict says what changed; this says what to do about what did not.
|
|
598
659
|
if (result.findings.length) report(result);
|
|
660
|
+
else notTestedLines(result).forEach(line);
|
|
599
661
|
saveRun(file, result);
|
|
600
662
|
process.exitCode = result.stopped ? 2 : verdict.allClear ? 0 : 1;
|
|
601
663
|
} finally {
|
|
@@ -621,5 +683,6 @@ module.exports = {
|
|
|
621
683
|
sweepOldCopies: sweepOldCopies,
|
|
622
684
|
notTestedLines: notTestedLines,
|
|
623
685
|
lastRunFile: lastRunFile,
|
|
686
|
+
exitCodeFor: exitCodeFor,
|
|
624
687
|
ABANDONED_AFTER: ABANDONED_AFTER,
|
|
625
688
|
};
|