kryptheon-night 0.1.7 → 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.
@@ -26,6 +26,7 @@ const path = require('path');
26
26
  const { Client } = require('pg');
27
27
 
28
28
  const scanner = require('../scan.js');
29
+ const store = require('../store.js');
29
30
  const recheck = require('../recheck.js');
30
31
  const intro = require('../intro.js');
31
32
  const trouble = require('../trouble.js');
@@ -485,7 +486,19 @@ async function main() {
485
486
  //
486
487
  // The flag still works, because anything scripted may be passing it, and
487
488
  // now it changes nothing: with an earlier run here, this always compares.
488
- const file = path.resolve(scanner.LAST_RUN);
489
+ let file;
490
+ try {
491
+ file = scanner.lastRunFile(line);
492
+ } catch (err) {
493
+ // Nowhere to keep this run means nothing to compare the next one against,
494
+ // and a re-check with nothing to compare against is not an answer.
495
+ fail('');
496
+ fail(' Kryptheon keeps its last run for this project in ' + store.dirFor(process.cwd()));
497
+ fail(' and could not write there: ' + err.message);
498
+ fail(' Set KRYPTHEON_HOME to a folder you can write to, and run this again.');
499
+ fail('');
500
+ process.exit(2);
501
+ }
489
502
  const before = scanner.loadLastRun(file);
490
503
  if (asked.recheck && !before) {
491
504
  // Asked for explicitly and impossible, which is worth saying. Arriving
@@ -548,12 +561,15 @@ async function main() {
548
561
  if (!before) {
549
562
  scanner.report(result);
550
563
  scanner.saveRun(file, result);
551
- process.exitCode = result.stopped ? 2 : result.findings.length ? 1 : 0;
564
+ process.exitCode = scanner.exitCodeFor(result);
552
565
  } else {
553
566
  const verdict = recheck.compare(before, result);
554
567
  recheck.describe(verdict).forEach(line);
555
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.
556
571
  if (result.findings.length) scanner.report(result);
572
+ else scanner.notTestedLines(result).forEach(line);
557
573
  scanner.saveRun(file, result);
558
574
  process.exitCode = result.stopped ? 2 : verdict.allClear ? 0 : 1;
559
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
- if (finding.expectation === 'identity') return 'CRITICAL';
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 === 'orphaned'
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
- 'Add a unique constraint on "' + finding.table + '"."' + finding.column +
422
- '" in the database itself. Checking in application code before inserting is not ' +
423
- 'enough - between the check and the insert, the other request has already gone in.',
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 the constraint ' +
428
- 'cover both columns together rather than leaving it off.',
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
- 'Then look for the same missing constraint on every other table and fix those too.',
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
- table: finding.table,
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
- cause: cause.long,
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 === 'duplicated'
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.7",
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",
@@ -24,15 +24,17 @@
24
24
  "README.md",
25
25
  "installer.js",
26
26
  "sqlengine.js",
27
- "engine.sql"
27
+ "engine.sql",
28
+ "store.js"
28
29
  ],
29
30
  "engines": {
30
31
  "node": ">=18.0.0"
31
32
  },
32
33
  "scripts": {
33
- "check": "node package.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node loop.check.js",
34
- "check:dry": "node package.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node recheck.check.js && node outside.check.js",
34
+ "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
+ "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",
35
36
  "check:package": "node package.check.js",
37
+ "check:store": "node store.check.js",
36
38
  "check:trouble": "node trouble.check.js",
37
39
  "check:intro": "node intro.check.js",
38
40
  "check:report": "node finding.check.js",
@@ -50,6 +52,8 @@
50
52
  "check:shapes": "node shapes.check.js",
51
53
  "check:verdicts": "node verdicts.check.js",
52
54
  "check:shop": "node shop.check.js",
55
+ "check:privileged": "node privileged.check.js",
56
+ "check:cli": "node cli.check.js",
53
57
  "check:writes": "node tamper.check.js",
54
58
  "check:orphans": "node orphans.check.js",
55
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
@@ -21,11 +21,26 @@ const collision = require('./collision.js');
21
21
  const tamper = require('./tamper.js');
22
22
  const orphan = require('./orphan.js');
23
23
  const recheck = require('./recheck.js');
24
-
25
- // Where the last run is kept so the next one has something to compare against.
26
- // Beside the person's own project, not in a temp folder, because a re-check a
27
- // week later has to find it.
28
- const LAST_RUN = '.kryptheon-last.json';
24
+ const store = require('./store.js');
25
+
26
+ // Where the last run is kept so the next one has something to compare against:
27
+ // the project's store under ~/.kryptheon, not a temp folder, because a
28
+ // re-check a week later has to find it - and not the project, because a saved
29
+ // run lists one app's tables and exactly where it is weak, which is the last
30
+ // thing that should ever be committed. It used to be .kryptheon-last.json in
31
+ // the project; the store moves one it finds there, once, and says so.
32
+ //
33
+ // Throws when the store cannot be made: a run that could not be saved would
34
+ // leave the next re-check nothing to hold anyone to.
35
+ function lastRunFile(say) {
36
+ const opened = store.open(process.cwd());
37
+ const lines = store.migrationLines(opened);
38
+ if (lines.length && say) {
39
+ say('');
40
+ lines.forEach(say);
41
+ }
42
+ return opened.nightLast;
43
+ }
29
44
 
30
45
  const line = (text) => process.stdout.write(text + '\n');
31
46
 
@@ -329,13 +344,41 @@ async function scan(client, sourceSchema, options) {
329
344
  }
330
345
  }
331
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
+
332
375
  return {
333
376
  stopped: null,
334
377
  attacksRun: attempted.length,
335
378
  notChecked: notChecked,
336
379
  attempted: attempted,
337
380
  findings: finding.describeAll(
338
- impersonation.findings.concat(writes.findings, stranded.findings, collisions.findings),
381
+ impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged),
339
382
  ),
340
383
  };
341
384
  } finally {
@@ -348,6 +391,17 @@ async function scan(client, sourceSchema, options) {
348
391
  }
349
392
  }
350
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
+
351
405
  /** The last run, or null if there has not been one worth keeping. */
352
406
  function loadLastRun(file) {
353
407
  try {
@@ -462,18 +516,29 @@ function report(result) {
462
516
 
463
517
  sayWhatWasMissed();
464
518
 
465
- const count = result.findings.length;
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');
466
524
  line('');
467
- line(' ' + count + (count === 1 ? ' problem' : ' problems') + ' found.');
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
+ }
468
532
 
469
533
  for (const item of result.findings) {
470
534
  line('');
471
535
  line(' ' + '-'.repeat(68));
472
- line(' ' + item.severity + ' ' + item.table);
536
+ line(' ' + item.severity + ' ' + item.table +
537
+ (item.status === 'verification required' ? ' (verification required)' : ''));
473
538
  line('');
474
539
  line(' ' + item.headline);
475
540
  line('');
476
- for (const paragraph of [item.body, item.cause]) {
541
+ for (const paragraph of [item.body, item.cause].filter(Boolean)) {
477
542
  wrap(paragraph, 70).forEach((l) => line(' ' + l));
478
543
  line('');
479
544
  }
@@ -530,7 +595,17 @@ async function main() {
530
595
  process.exit(2);
531
596
  }
532
597
 
533
- const file = path.resolve(LAST_RUN);
598
+ let file;
599
+ try {
600
+ file = lastRunFile(line);
601
+ } catch (err) {
602
+ // 2, not 1: nothing was attacked, so nothing "got through".
603
+ console.error('');
604
+ console.error(' There is nowhere to keep this run (' + err.message + ').');
605
+ console.error(' Set KRYPTHEON_HOME to a folder you can write to, and run this again.');
606
+ console.error('');
607
+ process.exit(2);
608
+ }
534
609
  const before = again ? loadLastRun(file) : null;
535
610
  if (again && !before) {
536
611
  // Running a fresh scan and calling it a re-check would report every
@@ -562,7 +637,7 @@ async function main() {
562
637
  // Three answers, three codes: 0 clean, 1 problems found, 2 could not
563
638
  // run. A scan that stopped used to exit 0 with no findings, which is
564
639
  // what any script watching it would read as a pass.
565
- process.exitCode = result.stopped ? 2 : result.findings.length ? 1 : 0;
640
+ process.exitCode = exitCodeFor(result);
566
641
  return;
567
642
  }
568
643
 
@@ -571,6 +646,7 @@ async function main() {
571
646
  recheck.badgeLines(verdict, result.attacksRun || 0).forEach(line);
572
647
  // The verdict says what changed; this says what to do about what did not.
573
648
  if (result.findings.length) report(result);
649
+ else notTestedLines(result).forEach(line);
574
650
  saveRun(file, result);
575
651
  process.exitCode = result.stopped ? 2 : verdict.allClear ? 0 : 1;
576
652
  } finally {
@@ -595,6 +671,7 @@ module.exports = {
595
671
  saveRun: saveRun,
596
672
  sweepOldCopies: sweepOldCopies,
597
673
  notTestedLines: notTestedLines,
598
- LAST_RUN: LAST_RUN,
674
+ lastRunFile: lastRunFile,
675
+ exitCodeFor: exitCodeFor,
599
676
  ABANDONED_AFTER: ABANDONED_AFTER,
600
677
  };