kryptheon-night 0.1.8 → 0.1.9
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/bin/kryptheon-night.js +4 -1
- package/finding.js +136 -15
- package/package.json +4 -2
- package/recheck.js +15 -0
- package/scan.js +58 -6
- package/schema.js +1210 -1137
- package/store.js +15 -0
package/bin/kryptheon-night.js
CHANGED
|
@@ -561,12 +561,15 @@ async function main() {
|
|
|
561
561
|
if (!before) {
|
|
562
562
|
scanner.report(result);
|
|
563
563
|
scanner.saveRun(file, result);
|
|
564
|
-
process.exitCode =
|
|
564
|
+
process.exitCode = scanner.exitCodeFor(result);
|
|
565
565
|
} else {
|
|
566
566
|
const verdict = recheck.compare(before, result);
|
|
567
567
|
recheck.describe(verdict).forEach(line);
|
|
568
568
|
recheck.badgeLines(verdict, result.attacksRun || 0).forEach(line);
|
|
569
|
+
// The report ends with what was not tested; with nothing left to report
|
|
570
|
+
// that list still has to be printed, or the re-check goes quiet about it.
|
|
569
571
|
if (result.findings.length) scanner.report(result);
|
|
572
|
+
else scanner.notTestedLines(result).forEach(line);
|
|
570
573
|
scanner.saveRun(file, result);
|
|
571
574
|
process.exitCode = result.stopped ? 2 : verdict.allClear ? 0 : 1;
|
|
572
575
|
}
|
package/finding.js
CHANGED
|
@@ -72,6 +72,14 @@ function listOf(items) {
|
|
|
72
72
|
* should be fixed - there is no such thing as a low finding here.
|
|
73
73
|
*/
|
|
74
74
|
function severityOf(finding, contents) {
|
|
75
|
+
if (finding.kind === 'privileged') {
|
|
76
|
+
// Not proven, so never CRITICAL: this is a reach that exists, not a break
|
|
77
|
+
// that was demonstrated. A function anon can call that also writes is worth
|
|
78
|
+
// more attention than one that only reads, but both stay HIGH and both
|
|
79
|
+
// carry "verification required" - the person has to say whether the anon
|
|
80
|
+
// call was intended.
|
|
81
|
+
return 'HIGH';
|
|
82
|
+
}
|
|
75
83
|
if (finding.kind === 'orphaned') {
|
|
76
84
|
// Nothing is exposed and nothing is destroyed, so this is not the same
|
|
77
85
|
// order of thing as a table anyone can empty. It stays serious because
|
|
@@ -90,7 +98,13 @@ function severityOf(finding, contents) {
|
|
|
90
98
|
// the table. A second row holding the same session token is critical even
|
|
91
99
|
// if the table has nothing personal in it at all.
|
|
92
100
|
if (finding.expectation === 'credential') return 'CRITICAL';
|
|
93
|
-
|
|
101
|
+
// Critical only while this column could be the login itself. When the
|
|
102
|
+
// database has Supabase's auth.users, sign-in goes through that table,
|
|
103
|
+
// which keeps every email unique - so a duplicate here is a copy that
|
|
104
|
+
// disagrees with itself, not two accounts sharing one login. Found on a
|
|
105
|
+
// real app: the report said "two accounts can answer to the same login"
|
|
106
|
+
// about a profiles table the login never read.
|
|
107
|
+
if (finding.expectation === 'identity') return finding.loginElsewhere ? 'HIGH' : 'CRITICAL';
|
|
94
108
|
return 'HIGH';
|
|
95
109
|
}
|
|
96
110
|
if (contents.secrets.length) return 'CRITICAL';
|
|
@@ -247,6 +261,9 @@ function headlineFor(finding) {
|
|
|
247
261
|
if (finding.kind === 'duplicated') {
|
|
248
262
|
return 'Your ' + finding.table + ' table lets the same ' + finding.column + ' exist twice.';
|
|
249
263
|
}
|
|
264
|
+
if (finding.kind === 'privileged') {
|
|
265
|
+
return 'Anyone on the internet can call your ' + finding.fn + ' function, and it runs with full rights.';
|
|
266
|
+
}
|
|
250
267
|
if (finding.kind === 'exposed') {
|
|
251
268
|
return 'Your ' + finding.table + ' ' + (finding.isView ? 'view' : 'table') + ' can be read by anyone.';
|
|
252
269
|
}
|
|
@@ -263,6 +280,17 @@ const COST_OF_A_DUPLICATE = {
|
|
|
263
280
|
code: 'A one-time code that can exist twice can be redeemed twice.',
|
|
264
281
|
};
|
|
265
282
|
|
|
283
|
+
// Said instead of the login sentence when sign-in happens in auth.users.
|
|
284
|
+
const COST_OF_A_COPIED_IDENTITY =
|
|
285
|
+
'Signing in is not affected: your app signs people in through auth.users, ' +
|
|
286
|
+
'which keeps each email unique. But anything that looks a person up by this ' +
|
|
287
|
+
'column can get two rows back and pick the wrong one.';
|
|
288
|
+
|
|
289
|
+
function costOf(finding) {
|
|
290
|
+
if (finding.expectation === 'identity' && finding.loginElsewhere) return COST_OF_A_COPIED_IDENTITY;
|
|
291
|
+
return COST_OF_A_DUPLICATE[finding.expectation] || '';
|
|
292
|
+
}
|
|
293
|
+
|
|
266
294
|
function bodyFor(finding, contents) {
|
|
267
295
|
const holds = listOf(contents.secrets.concat(contents.identity, contents.money));
|
|
268
296
|
const rowWord = finding.readable === 1 ? 'row' : 'rows';
|
|
@@ -300,14 +328,32 @@ function bodyFor(finding, contents) {
|
|
|
300
328
|
'kept, and your live app was never touched.';
|
|
301
329
|
}
|
|
302
330
|
|
|
331
|
+
if (finding.kind === 'privileged') {
|
|
332
|
+
// Said as what is true, not as what was done - nothing was executed. The
|
|
333
|
+
// reach is real; whether it is a mistake is the person's to say, so the
|
|
334
|
+
// wording asks rather than accuses.
|
|
335
|
+
return (
|
|
336
|
+
'This function is SECURITY DEFINER, so it runs as whoever created it, not ' +
|
|
337
|
+
'as the visitor calling it - which means the row level security on the tables ' +
|
|
338
|
+
'it touches is checked against the owner and skipped for the caller. It is ' +
|
|
339
|
+
'granted to anon, the role a visitor with no account uses, so anyone on the ' +
|
|
340
|
+
'internet can call it' +
|
|
341
|
+
(finding.writes ? ' - and it writes to your data, so a caller can change rows the rules would otherwise protect.'
|
|
342
|
+
: ', reaching whatever it reads past the rules on those tables.') +
|
|
343
|
+
' I did not call it: whether an open function like this is intended is ' +
|
|
344
|
+
'something only you can confirm.' +
|
|
345
|
+
(finding.hasFixedSearchPath ? ''
|
|
346
|
+
: ' It also does not pin its search_path, so a caller can point the names ' +
|
|
347
|
+
'inside it at their own objects.')
|
|
348
|
+
);
|
|
349
|
+
}
|
|
303
350
|
if (finding.kind === 'duplicated') {
|
|
304
351
|
// Said as what was done, not as what it implies. Two connections, one
|
|
305
352
|
// moment, both accepted, and here is the count afterwards.
|
|
306
353
|
return (
|
|
307
354
|
'I opened two connections to a copy of your app and inserted the same ' +
|
|
308
355
|
finding.column + ' from both at the same moment. Both were accepted, so there are ' +
|
|
309
|
-
'now ' + finding.copies + ' rows holding the identical value. ' +
|
|
310
|
-
(COST_OF_A_DUPLICATE[finding.expectation] || '')
|
|
356
|
+
'now ' + finding.copies + ' rows holding the identical value. ' + costOf(finding)
|
|
311
357
|
).trim();
|
|
312
358
|
}
|
|
313
359
|
|
|
@@ -368,7 +414,36 @@ function fixPromptFor(finding) {
|
|
|
368
414
|
const cause = causeOf(finding);
|
|
369
415
|
const owner = finding.owner ? '"' + finding.owner + '"' : 'the column that says who each row belongs to';
|
|
370
416
|
|
|
371
|
-
const lines = finding.kind === '
|
|
417
|
+
const lines = finding.kind === 'privileged'
|
|
418
|
+
? [
|
|
419
|
+
'My app may have a security problem - please check it rather than assume it.',
|
|
420
|
+
'',
|
|
421
|
+
'The database function "' + finding.fn + '(' + (finding.args || '') + ')" is SECURITY ' +
|
|
422
|
+
'DEFINER, so it runs with its owner\'s rights and ignores row level security. It is ' +
|
|
423
|
+
'granted EXECUTE to "anon", so anyone who is not logged in can call it' +
|
|
424
|
+
(finding.writes ? ', and it writes to tables.' : '.'),
|
|
425
|
+
'',
|
|
426
|
+
'First tell me: is it meant to be callable by logged-out visitors? If it is not, ' +
|
|
427
|
+
'REVOKE EXECUTE ON FUNCTION "' + finding.fn + '"(' + (finding.args || '') + ') FROM anon, PUBLIC, ' +
|
|
428
|
+
'and GRANT EXECUTE only to the role that should run it - usually service_role, the ' +
|
|
429
|
+
'backend key, the way an admin-only function like this should be reached.',
|
|
430
|
+
'',
|
|
431
|
+
].concat(
|
|
432
|
+
finding.hasFixedSearchPath
|
|
433
|
+
? []
|
|
434
|
+
: [
|
|
435
|
+
'It also does not set a fixed search_path. Add SET search_path = pg_catalog, public (or ' +
|
|
436
|
+
'the schemas it truly needs) so a caller cannot make it resolve names to their own objects.',
|
|
437
|
+
'',
|
|
438
|
+
],
|
|
439
|
+
[
|
|
440
|
+
'If it is meant to be public, keep it, but make sure everything inside it checks what the ' +
|
|
441
|
+
'caller is allowed to see or change itself - the table rules will not do it here.',
|
|
442
|
+
'',
|
|
443
|
+
'Then look at every other SECURITY DEFINER function for the same grant.',
|
|
444
|
+
],
|
|
445
|
+
)
|
|
446
|
+
: finding.kind === 'orphaned'
|
|
372
447
|
? [
|
|
373
448
|
'My app has a data problem.',
|
|
374
449
|
'',
|
|
@@ -418,17 +493,50 @@ function fixPromptFor(finding) {
|
|
|
418
493
|
'". I proved it by inserting the same value from two connections at the same ' +
|
|
419
494
|
'moment, and both were accepted.',
|
|
420
495
|
'',
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
496
|
+
// First, because it has to happen before anything is changed. Learned
|
|
497
|
+
// by applying the short version of this prompt literally to a real app:
|
|
498
|
+
// the constraint cannot be created while duplicates exist, and an
|
|
499
|
+
// assistant told only "add a constraint" may delete rows to get there.
|
|
500
|
+
'First, find the rows that already share a value and show them to me. Do not ' +
|
|
501
|
+
'delete or merge any of them without asking me: the rule below cannot be ' +
|
|
502
|
+
'created while they exist, and deleting rows to make it pass loses real data.',
|
|
503
|
+
'',
|
|
504
|
+
(finding.expectation === 'identity'
|
|
505
|
+
? 'Then add a unique index on lower("' + finding.column + '"), so that "' + finding.table +
|
|
506
|
+
'"."' + finding.column + '" is unique whatever the case - Ann@example.com and ' +
|
|
507
|
+
'ann@example.com are the same person.'
|
|
508
|
+
: 'Then add a unique constraint on "' + finding.table + '"."' + finding.column + '".') +
|
|
509
|
+
' It has to be in the database itself. Checking in application code before inserting ' +
|
|
510
|
+
'is not enough - between the check and the insert, the other request has already gone in.',
|
|
424
511
|
'',
|
|
425
512
|
// Said because the obvious fix is wrong for multi-tenant apps, and being
|
|
426
513
|
// told to drop a legitimate design would cost them more than the bug.
|
|
427
|
-
'If the same value is allowed to repeat for different owners, make
|
|
428
|
-
'
|
|
514
|
+
'If the same value is allowed to repeat for different owners, make it cover both ' +
|
|
515
|
+
'columns together rather than leaving it off.',
|
|
429
516
|
'',
|
|
430
|
-
|
|
431
|
-
|
|
517
|
+
].concat(
|
|
518
|
+
[
|
|
519
|
+
'Find everything that writes this column - app code, and any database trigger or ' +
|
|
520
|
+
'function, such as one that copies each new sign-up into this table - and make ' +
|
|
521
|
+
'sure each one handles "this value already exists" instead of failing. If people ' +
|
|
522
|
+
'can edit this column themselves, decide whether they should: once it is unique, ' +
|
|
523
|
+
'someone who puts another person\'s value in first will block that person.',
|
|
524
|
+
'',
|
|
525
|
+
],
|
|
526
|
+
finding.expectation === 'identity' && finding.loginElsewhere
|
|
527
|
+
? [
|
|
528
|
+
'Sign-in uses auth.users, so this column is only a copy of the email there. ' +
|
|
529
|
+
'Consider not letting people edit it at all and keeping it in step with auth.users.',
|
|
530
|
+
'',
|
|
531
|
+
]
|
|
532
|
+
: [],
|
|
533
|
+
[
|
|
534
|
+
'Afterwards, sign up a brand-new account and edit an existing profile to make sure ' +
|
|
535
|
+
'both still work, and run this check again.',
|
|
536
|
+
'',
|
|
537
|
+
'Then look for the same missing constraint on every other table and fix those too.',
|
|
538
|
+
],
|
|
539
|
+
)
|
|
432
540
|
: finding.isView
|
|
433
541
|
? [
|
|
434
542
|
'My app has a security problem.',
|
|
@@ -471,7 +579,15 @@ function describe(finding) {
|
|
|
471
579
|
const cause = causeOf(finding);
|
|
472
580
|
return {
|
|
473
581
|
severity: severityOf(finding, contents),
|
|
474
|
-
|
|
582
|
+
// Every other finding here was proven by an attack that ran; this is the
|
|
583
|
+
// one the tool reasons about without executing, so it says so. The report
|
|
584
|
+
// and the re-check both read this: a "verification required" finding is
|
|
585
|
+
// never counted towards a clean re-check on its own.
|
|
586
|
+
status: finding.kind === 'privileged' ? 'verification required' : 'confirmed',
|
|
587
|
+
table: finding.kind === 'privileged' ? finding.fn : finding.table,
|
|
588
|
+
fn: finding.fn,
|
|
589
|
+
args: finding.args,
|
|
590
|
+
writes: finding.writes,
|
|
475
591
|
kind: finding.kind,
|
|
476
592
|
// Carried through because a table can have two different columns that each
|
|
477
593
|
// accept a duplicate, and the re-check tells one finding from another by
|
|
@@ -483,10 +599,15 @@ function describe(finding) {
|
|
|
483
599
|
expectation: finding.expectation,
|
|
484
600
|
headline: headlineFor(finding, contents),
|
|
485
601
|
body: bodyFor(finding, contents),
|
|
486
|
-
|
|
602
|
+
// For a privileged function there is no "why the door is open" the way a
|
|
603
|
+
// table has one; the body already carries the whole explanation, so the
|
|
604
|
+
// cause line would only repeat it. Left empty and skipped by the report.
|
|
605
|
+
cause: finding.kind === 'privileged' ? '' : cause.long,
|
|
487
606
|
who: finding.who,
|
|
488
607
|
can: finding.can,
|
|
489
|
-
proof: finding.kind === '
|
|
608
|
+
proof: finding.kind === 'privileged'
|
|
609
|
+
? 'I did not call "' + finding.fn + '". This is a reach that exists in the grants, not a break I ran.'
|
|
610
|
+
: finding.kind === 'duplicated'
|
|
490
611
|
? 'I created ' + finding.copies + ' rows in "' + finding.table + '" holding the same ' +
|
|
491
612
|
finding.column + '.'
|
|
492
613
|
: finding.countOnly
|
|
@@ -524,7 +645,7 @@ function describeAll(findings) {
|
|
|
524
645
|
// ones that need an account, and those before the ones that need two requests
|
|
525
646
|
// to arrive together.
|
|
526
647
|
// Writes above reads: a table somebody emptied is worse than one they read.
|
|
527
|
-
const byKind = { writable: 0, exposed: 1, crossed: 2, duplicated: 3, orphaned: 4 };
|
|
648
|
+
const byKind = { writable: 0, exposed: 1, crossed: 2, duplicated: 3, orphaned: 4, privileged: 5 };
|
|
528
649
|
const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
|
|
529
650
|
return described.sort((a, b) => rank(a) - rank(b));
|
|
530
651
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kryptheon-night",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
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
|
@@ -116,6 +116,9 @@ function compare(before, after) {
|
|
|
116
116
|
stillOpen: stillOpen,
|
|
117
117
|
unverifiable: unverifiable,
|
|
118
118
|
newlyBroken: newlyBroken,
|
|
119
|
+
// Carried so the re-check can say why there is no green light when the
|
|
120
|
+
// only thing standing in the way is what could not be tested.
|
|
121
|
+
untested: skipped,
|
|
119
122
|
// Green is the strictest thing this program says, so it is the hardest to
|
|
120
123
|
// earn: everything that was wrong is now provably right, nothing new broke,
|
|
121
124
|
// and nothing at all was left untested.
|
|
@@ -208,6 +211,18 @@ function describe(result) {
|
|
|
208
211
|
lines.push('');
|
|
209
212
|
}
|
|
210
213
|
|
|
214
|
+
// Found on a real app: "1 problem is fixed", nothing else, and a non-zero
|
|
215
|
+
// exit with no word about why. The reason was parts of the app that could
|
|
216
|
+
// not be tested - which has to be said, or the exit code reads as a bug.
|
|
217
|
+
const untested = result.untested || [];
|
|
218
|
+
if (untested.length) {
|
|
219
|
+
lines.push(' No green light yet: ' + untested.length + ' ' +
|
|
220
|
+
(untested.length === 1 ? 'part of your app was' : 'parts of your app were') +
|
|
221
|
+
' not tested this time, so I cannot');
|
|
222
|
+
lines.push(' call it clear. They are listed at the end, with the reason for each.');
|
|
223
|
+
lines.push('');
|
|
224
|
+
}
|
|
225
|
+
|
|
211
226
|
return lines;
|
|
212
227
|
}
|
|
213
228
|
|
package/scan.js
CHANGED
|
@@ -344,13 +344,41 @@ 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
|
+
|
|
347
375
|
return {
|
|
348
376
|
stopped: null,
|
|
349
377
|
attacksRun: attempted.length,
|
|
350
378
|
notChecked: notChecked,
|
|
351
379
|
attempted: attempted,
|
|
352
380
|
findings: finding.describeAll(
|
|
353
|
-
impersonation.findings.concat(writes.findings, stranded.findings,
|
|
381
|
+
impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged),
|
|
354
382
|
),
|
|
355
383
|
};
|
|
356
384
|
} finally {
|
|
@@ -363,6 +391,17 @@ async function scan(client, sourceSchema, options) {
|
|
|
363
391
|
}
|
|
364
392
|
}
|
|
365
393
|
|
|
394
|
+
/**
|
|
395
|
+
* The exit code of a first run: 2 could not run, 1 something got through, 0
|
|
396
|
+
* every attack lost. Only confirmed findings count as "got through" - a
|
|
397
|
+
* function flagged for verification was never executed, and a script reading
|
|
398
|
+
* 1 would act on a break nobody demonstrated.
|
|
399
|
+
*/
|
|
400
|
+
function exitCodeFor(result) {
|
|
401
|
+
if (result.stopped) return 2;
|
|
402
|
+
return (result.findings || []).some((f) => f.status !== 'verification required') ? 1 : 0;
|
|
403
|
+
}
|
|
404
|
+
|
|
366
405
|
/** The last run, or null if there has not been one worth keeping. */
|
|
367
406
|
function loadLastRun(file) {
|
|
368
407
|
try {
|
|
@@ -477,18 +516,29 @@ function report(result) {
|
|
|
477
516
|
|
|
478
517
|
sayWhatWasMissed();
|
|
479
518
|
|
|
480
|
-
|
|
519
|
+
// Confirmed breaks and things that only need a look are not the same claim,
|
|
520
|
+
// so they are counted apart. Saying "3 problems" when one of them was never
|
|
521
|
+
// executed is the kind of overstatement that costs the next report its trust.
|
|
522
|
+
const confirmed = result.findings.filter((f) => f.status !== 'verification required');
|
|
523
|
+
const toVerify = result.findings.filter((f) => f.status === 'verification required');
|
|
481
524
|
line('');
|
|
482
|
-
|
|
525
|
+
if (confirmed.length) {
|
|
526
|
+
line(' ' + confirmed.length + (confirmed.length === 1 ? ' problem' : ' problems') + ' found.');
|
|
527
|
+
}
|
|
528
|
+
if (toVerify.length) {
|
|
529
|
+
line(' ' + toVerify.length + ' thing' + (toVerify.length === 1 ? '' : 's') +
|
|
530
|
+
' to check - I did not attack ' + (toVerify.length === 1 ? 'it' : 'them') + ', only spotted the risk.');
|
|
531
|
+
}
|
|
483
532
|
|
|
484
533
|
for (const item of result.findings) {
|
|
485
534
|
line('');
|
|
486
535
|
line(' ' + '-'.repeat(68));
|
|
487
|
-
line(' ' + item.severity + ' ' + item.table
|
|
536
|
+
line(' ' + item.severity + ' ' + item.table +
|
|
537
|
+
(item.status === 'verification required' ? ' (verification required)' : ''));
|
|
488
538
|
line('');
|
|
489
539
|
line(' ' + item.headline);
|
|
490
540
|
line('');
|
|
491
|
-
for (const paragraph of [item.body, item.cause]) {
|
|
541
|
+
for (const paragraph of [item.body, item.cause].filter(Boolean)) {
|
|
492
542
|
wrap(paragraph, 70).forEach((l) => line(' ' + l));
|
|
493
543
|
line('');
|
|
494
544
|
}
|
|
@@ -587,7 +637,7 @@ async function main() {
|
|
|
587
637
|
// Three answers, three codes: 0 clean, 1 problems found, 2 could not
|
|
588
638
|
// run. A scan that stopped used to exit 0 with no findings, which is
|
|
589
639
|
// what any script watching it would read as a pass.
|
|
590
|
-
process.exitCode = result
|
|
640
|
+
process.exitCode = exitCodeFor(result);
|
|
591
641
|
return;
|
|
592
642
|
}
|
|
593
643
|
|
|
@@ -596,6 +646,7 @@ async function main() {
|
|
|
596
646
|
recheck.badgeLines(verdict, result.attacksRun || 0).forEach(line);
|
|
597
647
|
// The verdict says what changed; this says what to do about what did not.
|
|
598
648
|
if (result.findings.length) report(result);
|
|
649
|
+
else notTestedLines(result).forEach(line);
|
|
599
650
|
saveRun(file, result);
|
|
600
651
|
process.exitCode = result.stopped ? 2 : verdict.allClear ? 0 : 1;
|
|
601
652
|
} finally {
|
|
@@ -621,5 +672,6 @@ module.exports = {
|
|
|
621
672
|
sweepOldCopies: sweepOldCopies,
|
|
622
673
|
notTestedLines: notTestedLines,
|
|
623
674
|
lastRunFile: lastRunFile,
|
|
675
|
+
exitCodeFor: exitCodeFor,
|
|
624
676
|
ABANDONED_AFTER: ABANDONED_AFTER,
|
|
625
677
|
};
|