kryptheon 0.1.26 → 0.1.28

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.
@@ -1,608 +1,864 @@
1
- // The local dashboard: one page on this machine that shows everything
2
- // Kryptheon knows about one project, and switches its checks on and off.
3
- //
4
- // npx kryptheon (in a terminal) or npx kryptheon dashboard
5
- //
6
- // Everything here reads the project's store (kryptheon-store.js); nothing is
7
- // kept anywhere else, and nothing leaves the machine. The server listens on
8
- // 127.0.0.1 only.
9
- //
10
- // Two rules the page is built around:
11
- //
12
- // - A check that does not exist yet is shown as "not available". Never as
13
- // passed, never as green, never counted. An empty row that looks like a
14
- // clean one is the most expensive lie this product could tell.
15
- // - Every value shown comes from a file that may hold text from the app being
16
- // checked - a failing page's heading, a line of the app's own code. The page
17
- // writes all of it with textContent, so the dashboard cannot become the XSS
18
- // it is there to find. Its own code is read by kryptheon-code.js in its check.
19
-
20
- const fs = require('fs');
21
- const path = require('path');
22
- const http = require('http');
23
- const crypto = require('crypto');
24
- const { spawnSync } = require('child_process');
25
- const store = require('./kryptheon-store.js');
26
- const changes = require('./kryptheon-changes.js');
27
-
28
- /**
29
- * Every check the dashboard knows the name of, in the order the person asked
30
- * for them. `available` is true only for what is actually built. What runs it
31
- * and where its results come from is said here, once.
32
- */
33
- const CHECKS = [
34
- { id: 'regression', label: 'Regression (recorded flows)', available: true,
35
- what: 'Replays every recording in tests/ and compares each page with the last time it worked.' },
36
- { id: 'frontend', label: 'Frontend: text inserted as HTML', available: true,
37
- what: 'Reads your frontend code for values put into the page with innerHTML and similar (npx kryptheon code).' },
38
- { id: 'data', label: 'Database: access rules, duplicates, orphans', available: true,
39
- what: 'Attacks a copy of your Postgres/Supabase database (npx kryptheon-night). Needs a connection string, so it is run from the terminal and read here.' },
40
- { id: 'build', label: 'Build', available: false },
41
- { id: 'backend', label: 'Backend', available: false },
42
- { id: 'api', label: 'API', available: false },
43
- { id: 'integration', label: 'Integration', available: false },
44
- { id: 'dependency', label: 'Dependencies', available: false },
45
- { id: 'runtime', label: 'Runtime', available: false },
46
- { id: 'performance', label: 'Performance', available: false },
47
- { id: 'edge', label: 'Input and edge cases', available: false },
48
- { id: 'reliability', label: 'Reliability', available: false },
49
- { id: 'synthetic', label: 'Synthetic test users', available: false },
50
- { id: 'eligibility', label: 'Age and eligibility rules', available: false },
51
- ];
52
-
53
- const CHECK_IDS = new Set(CHECKS.map((c) => c.id));
54
-
55
- function readJson(file) {
56
- try {
57
- let raw = fs.readFileSync(file, 'utf8');
58
- if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
59
- return JSON.parse(raw);
60
- } catch (err) {
61
- return null;
62
- }
63
- }
64
-
65
- function readHistory(file, keep) {
66
- let raw;
67
- try {
68
- raw = fs.readFileSync(file, 'utf8');
69
- } catch (err) {
70
- return [];
71
- }
72
- const runs = [];
73
- for (const line of raw.split('\n')) {
74
- if (!line.trim()) continue;
75
- try {
76
- runs.push(JSON.parse(line));
77
- } catch (err) {
78
- /* a damaged line is skipped, not fatal */
79
- }
80
- }
81
- return runs.slice(-keep);
82
- }
83
-
84
- /** Which checks are switched on. Available checks default to on; others can never be. */
85
- function readConfig(paths) {
86
- const saved = readJson(paths.config) || {};
87
- const enabled = {};
88
- for (const c of CHECKS) {
89
- enabled[c.id] = c.available ? (saved.enabled && typeof saved.enabled[c.id] === 'boolean' ? saved.enabled[c.id] : true) : false;
90
- }
91
- return { enabled: enabled };
92
- }
93
-
94
- /**
95
- * Switches one check on or off, and keeps it. Refuses what does not exist and
96
- * what is not built: a "not available" check switched on would read as a
97
- * promise that something is being checked.
98
- */
99
- function setCheck(paths, id, on) {
100
- if (!CHECK_IDS.has(id)) return { ok: false, why: 'there is no check called "' + id + '"' };
101
- const check = CHECKS.find((c) => c.id === id);
102
- if (!check.available) return { ok: false, why: check.label + ' is not available yet, so it cannot be switched on' };
103
- if (typeof on !== 'boolean') return { ok: false, why: 'on must be true or false' };
104
- const config = readConfig(paths);
105
- config.enabled[id] = on;
106
- fs.mkdirSync(path.dirname(paths.config), { recursive: true });
107
- fs.writeFileSync(paths.config, JSON.stringify(config, null, 2) + '\n', 'utf8');
108
- return { ok: true, config: config };
109
- }
110
-
111
- /** What git says changed, read only. Null when this is not a git repository. */
112
- function recentChanges(root) {
113
- const git = (args) => {
114
- const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 5000 });
115
- return r.status === 0 ? String(r.stdout || '') : null;
116
- };
117
- const inside = git(['rev-parse', '--is-inside-work-tree']);
118
- if (!inside || inside.trim() !== 'true') return null;
119
- const log = git(['log', '-5', '--pretty=format:%h%x09%cI%x09%s']) || '';
120
- const status = git(['status', '--porcelain']) || '';
121
- return {
122
- commits: log.split('\n').filter(Boolean).map((l) => {
123
- const [hash, when, subject] = l.split('\t');
124
- return { hash: hash, when: when, subject: subject };
125
- }),
126
- uncommitted: status.split('\n').filter(Boolean).map((l) => ({ state: l.slice(0, 2).trim(), file: l.slice(3) })),
127
- };
128
- }
129
-
130
- /** " - the risky part: x" for a frontend finding that names one, else "". */
131
- function riskyPartsLine(f) {
132
- const parts = (f.parts || []).filter((p) => p && p.text && p.text !== f.expression);
133
- if (!parts.length) return '';
134
- return ' - the risky part' + (parts.length === 1 ? ': ' : 's: ') + parts.map((p) => p.text).join(', ');
135
- }
136
-
137
- /** When this flow last passed, from the run history, or null. */
138
- function lastPassOf(history, title) {
139
- for (let i = history.length - 1; i >= 0; i--) {
140
- const t = (history[i].tests || []).find((x) => x.title === title);
141
- if (t && t.status === 'passed') return history[i].runAt;
142
- }
143
- return null;
144
- }
145
-
146
- /**
147
- * The fix prompt for a broken recorded flow, from what the run kept: what was
148
- * seen, what was expected, where, the evidence, and how to see it again. Only
149
- * what the run recorded is said; nothing is guessed.
150
- */
151
- function flowPrompt(t, lastPass) {
152
- const f = t.failure || {};
153
- const o = f.observations || {};
154
- const file = String(f.file || t.specFile || 'tests').split('\\').join('/');
155
- const extra = String(f.rawMessage || '').split(/\r?\n/).slice(1)
156
- .map((l) => l.trim()).filter((l) => l && !/^\s*at\s/.test(l) && l.length < 200).slice(0, 6);
157
- const lines = [
158
- 'My app has a broken flow. Please fix the app - and nothing else.',
159
- '',
160
- 'Kryptheon replayed the recorded flow "' + t.title + '" (' + file + (f.line ? ', line ' + f.line : '') + ') and it failed.',
161
- '',
162
- 'Observed: ' + (f.plainLanguage || 'the replay failed'),
163
- ].concat(extra.map((l) => ' ' + l), [
164
- 'Expected: the same result as ' + (lastPass ? 'the last time it passed (' + lastPass + ')' : 'when it was recorded') + '.',
165
- ]);
166
- if (o.url) lines.push('Where: the browser was on ' + o.url + (o.pageShowed ? ', and the page showed ' + o.pageShowed : '') + '.');
167
- if (f.locator) lines.push('Step that failed: ' + f.locator);
168
- if (o.failedRequests && o.failedRequests.length) lines.push('Failed requests: ' + o.failedRequests.slice(0, 5).map((r) => (r.method || 'GET') + ' ' + (r.path || r.url) + ' -> ' + r.status).join('; '));
169
- if (o.consoleErrors && o.consoleErrors.length) lines.push('Console errors: ' + o.consoleErrors.slice(0, 3).join(' | '));
170
- if (f.screenshot) lines.push('Evidence: a screenshot of the failure at ' + f.screenshot);
171
- lines.push('');
172
- lines.push('To see it yourself: open ' + (o.url || 'the app') + ' and repeat the steps in ' + file + ', or run npx kryptheon check');
173
- lines.push('');
174
- lines.push('Fix the app so this flow works again. Do not edit the recording to make it pass. If the new behaviour ' +
175
- 'is intended, say so instead of changing code - it can be accepted with npx kryptheon accept "' + t.title + '"');
176
- return lines.join('\n');
177
- }
178
-
179
- function countRecordings(root) {
180
- try {
181
- return fs.readdirSync(path.join(root, 'tests')).filter((n) => /\.(spec|test)\.[cm]?[jt]s$/.test(n)).length;
182
- } catch (err) {
183
- return 0;
184
- }
185
- }
186
-
187
- /**
188
- * The order someone new should go in, one step at a time. A step is "done"
189
- * only when it has actually run and left a record - never because nothing was
190
- * found - and a switched-off check says "off", so an empty list is never read
191
- * as a clean one.
192
- */
193
- function startSteps(checks, seen) {
194
- const on = (id) => (checks.find((c) => c.id === id) || {}).enabled;
195
- return [
196
- {
197
- id: 'frontend',
198
- title: 'Read your frontend code',
199
- why: 'Finds places where text could be put into your page as HTML. Needs nothing from you.',
200
- state: !on('frontend') ? 'off' : seen.codeAt ? 'done' : 'todo',
201
- at: seen.codeAt,
202
- action: on('frontend') ? { kind: 'run', check: 'frontend', label: seen.codeAt ? 'Read it again' : 'Read my code' } : null,
203
- },
204
- {
205
- id: 'record',
206
- title: 'Record your main flows',
207
- // The lesson of a test where 26 bugs were planted before recording and
208
- // none was caught: what is recorded is taken as right.
209
- why: 'Sign up, log in, pay - do each once while Kryptheon watches. Record while your app works: what it sees is taken as right, so a bug already there when you record becomes part of "working".',
210
- state: seen.recordings ? 'done' : 'todo',
211
- at: null,
212
- count: seen.recordings,
213
- action: { kind: 'command', command: 'npx kryptheon record <your app address>' },
214
- },
215
- {
216
- id: 'regression',
217
- title: 'Replay them',
218
- why: 'Runs every recording against your app and compares each page with the last time it worked. Start your app first.',
219
- state: !on('regression') ? 'off' : !seen.recordings ? 'waiting' : seen.lastRunAt ? 'done' : 'todo',
220
- at: seen.lastRunAt,
221
- action: on('regression') && seen.recordings ? { kind: 'run', check: 'regression', label: seen.lastRunAt ? 'Replay again' : 'Replay now' } : null,
222
- },
223
- {
224
- id: 'data',
225
- title: 'Check your database',
226
- why: 'Attacks a copy inside your Postgres or Supabase database and reports what got in; your own rows are not touched. It asks for the connection string - in Supabase: Project Settings, Database, Connection string, URI. Try it on a test project first. It runs in your terminal, so the string never reaches this page.',
227
- state: !on('data') ? 'off' : seen.nightAt ? 'done' : 'todo',
228
- at: seen.nightAt,
229
- action: on('data') ? { kind: 'command', command: 'npx kryptheon-night' } : null,
230
- },
231
- ];
232
- }
233
-
234
- /**
235
- * The one thing to do now. Open confirmed problems come before everything; then
236
- * the first step not taken; then what only needs a look; then nothing.
237
- */
238
- function nextAction(findings, steps) {
239
- const confirmed = findings.filter((f) => f.status === 'confirmed').length;
240
- const toVerify = findings.length - confirmed;
241
- if (confirmed) {
242
- return { kind: 'fix', view: 'findings', count: confirmed,
243
- title: confirmed === 1 ? 'Fix 1 confirmed problem' : 'Fix ' + confirmed + ' confirmed problems' };
244
- }
245
- const step = steps.find((s) => s.state === 'todo');
246
- if (step) return { kind: 'step', step: step.id, title: step.title };
247
- if (toVerify) {
248
- return { kind: 'verify', view: 'findings', count: toVerify,
249
- title: toVerify === 1 ? 'Look at 1 thing to verify' : 'Look at ' + toVerify + ' things to verify' };
250
- }
251
- return { kind: 'quiet', title: 'Nothing open' };
252
- }
253
-
254
- /**
255
- * One sentence about the nightly run inside the database, or null when
256
- * kryptheon-night has never been asked. Its four states are four different
257
- * answers, and none of the other three may read as "nothing found": not set
258
- * up, set up and never run, set up with nothing scheduled, and stopped.
259
- */
260
- function nightlyLine(nightly) {
261
- if (!nightly) return null;
262
- const day = (iso) => String(iso || '').slice(0, 16).replace('T', ' ');
263
- const read = ' (as of ' + day(nightly.readAt) + ')';
264
- if (!nightly.installed) return 'Nightly run in your database: not set up. npx kryptheon-night install sets it up.' + read;
265
- const idle = nightly.active ? '' : ' Nothing is scheduled, so it is not running - npx kryptheon-night install sets it up again.';
266
- if (!nightly.ranAt) return 'Nightly run in your database: set up, and it has not run yet.' + idle + read;
267
- const when = 'Nightly run in your database, ' + day(nightly.ranAt) + ': ';
268
- if (nightly.stopped) return when + 'it could not check - ' + nightly.stopped + idle + read;
269
- const n = (nightly.findings || []).length;
270
- const skipped = (nightly.notChecked || []).length;
271
- return when + (n ? n + (n === 1 ? ' problem found.' : ' problems found.')
272
- : 'nothing got through ' + (nightly.attacksRun || 0) + ' attacks.') +
273
- (skipped ? ' ' + skipped + ' ' + (skipped === 1 ? 'part was' : 'parts were') + ' not tested.' : '') + idle + read;
274
- }
275
-
276
- /**
277
- * Everything the page shows, from the store alone. Findings from every check
278
- * are put into one list, each carrying the same fields - severity, status,
279
- * evidence, confidence, where, what, and the fix - so the page can show them
280
- * the same way whichever check found them.
281
- */
282
- function buildState(root, env) {
283
- const paths = store.pathsFor(root, env);
284
- const config = readConfig(paths);
285
- const history = readHistory(paths.history, 20);
286
- const baselines = readJson(paths.baselines) || {};
287
- const code = readJson(paths.codeFindings);
288
- const night = readJson(paths.nightLast);
289
- // What the nightly run inside the database last said, kept by
290
- // kryptheon-night whenever it connects. Not the same answer as night:
291
- // that one is the scan last run from this terminal.
292
- const nightly = readJson(paths.nightNightly);
293
-
294
- const findings = [];
295
-
296
- // Recorded flows: the last run's broken tests are the findings. They were
297
- // replayed in a real browser, so they are confirmed at runtime.
298
- const lastRun = history.length ? history[history.length - 1] : null;
299
- if (lastRun && config.enabled.regression) {
300
- for (const t of lastRun.tests || []) {
301
- if (t.status !== 'failed' && t.status !== 'timedOut') continue;
302
- findings.push({
303
- check: 'regression',
304
- severity: 'HIGH',
305
- status: 'confirmed',
306
- evidence: 'runtime confirmed',
307
- confidence: 'high',
308
- where: String((t.failure && t.failure.file) || t.specFile || 'tests').split('\\').join('/') +
309
- (t.failure && t.failure.line ? ':' + t.failure.line : ''),
310
- headline: 'The recorded flow "' + t.title + '" broke.',
311
- detail: (t.failure && t.failure.plainLanguage) || '',
312
- fixPrompt: flowPrompt(t, lastPassOf(history, t.title)),
313
- when: lastRun.runAt,
314
- });
315
- }
316
- }
317
-
318
- if (code && config.enabled.frontend) {
319
- for (const f of code.findings || []) {
320
- findings.push({
321
- check: 'frontend',
322
- severity: f.severity,
323
- status: f.status,
324
- evidence: f.evidence,
325
- confidence: f.confidence,
326
- where: f.file + ':' + f.line,
327
- headline: f.headline,
328
- // The part of a long template that decides it, so the person reading
329
- // it looks at one value instead of sixty lines of markup.
330
- detail: f.sink + ': ' + f.expression + riskyPartsLine(f),
331
- fixPrompt: f.fixPrompt,
332
- when: code.checkedAt,
333
- });
334
- }
335
- }
336
-
337
- let nightWhen = null;
338
- try {
339
- nightWhen = fs.statSync(paths.nightLast).mtime.toISOString();
340
- } catch (err) {
341
- /* never run */
342
- }
343
- if (night && config.enabled.data) {
344
- for (const f of night.findings || []) {
345
- findings.push({
346
- check: 'data',
347
- severity: f.severity,
348
- status: f.status || 'confirmed',
349
- evidence: (f.status || 'confirmed') === 'confirmed' ? 'runtime confirmed' : 'code analysis',
350
- confidence: (f.status || 'confirmed') === 'confirmed' ? 'high' : 'medium',
351
- where: f.table + (f.column ? '.' + f.column : ''),
352
- headline: f.headline,
353
- detail: f.body || '',
354
- fixPrompt: f.fixPrompt || '',
355
- when: nightWhen,
356
- });
357
- }
358
- }
359
-
360
- // The nightly run's findings, said as coming from it. One both answers
361
- // share is shown once, with the newer date - the same problem found twice
362
- // is still one problem to fix.
363
- if (nightly && nightly.ranAt && config.enabled.data) {
364
- for (const f of nightly.findings || []) {
365
- const item = {
366
- check: 'data',
367
- from: 'nightly',
368
- severity: f.severity,
369
- status: f.status || 'confirmed',
370
- evidence: (f.status || 'confirmed') === 'confirmed' ? 'runtime confirmed' : 'code analysis',
371
- confidence: (f.status || 'confirmed') === 'confirmed' ? 'high' : 'medium',
372
- where: f.table + (f.column ? '.' + f.column : ''),
373
- headline: f.headline,
374
- detail: f.body || '',
375
- fixPrompt: f.fixPrompt || '',
376
- when: nightly.ranAt,
377
- };
378
- const same = findings.find((o) => o.check === 'data' && changes.findingKey(o) === changes.findingKey(item));
379
- if (!same) findings.push(item);
380
- else {
381
- same.from = 'both';
382
- if (!same.when || item.when > same.when) same.when = item.when;
383
- }
384
- }
385
- }
386
-
387
- const rank = { 'HIGH CRITICAL': 0, CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
388
- findings.sort((a, b) => (rank[a.severity] ?? 9) - (rank[b.severity] ?? 9) ||
389
- (a.status === 'confirmed' ? 0 : 1) - (b.status === 'confirmed' ? 0 : 1));
390
-
391
- // Every finding gets a short id and the last word of its fix prompt: how to
392
- // prove the fix. Added here, where the loop lives, so every check's prompt
393
- // ends the same way and the detection code itself stays untouched.
394
- const attempts = readHistory(paths.fixes, 500);
395
- for (const f of findings) {
396
- f.id = changes.findingId(f);
397
- f.attempts = attempts.filter((a) => a.id === f.id).map((a) => ({ at: a.at, verdict: a.verdict, why: a.why || '', newProblems: (a.newProblems || []).length }));
398
- if (f.fixPrompt) {
399
- f.fixPrompt += '\n\nWhen you have fixed it, prove it: run npx kryptheon recheck ' + f.id +
400
- ' (or press Re-check on this finding in the Kryptheon dashboard). It is fixed only when that says FIXED.';
401
- }
402
- }
403
- // Fixes that were proven, newest first, while their finding is still gone.
404
- const open = new Set(findings.map((f) => f.id));
405
- const proven = attempts.filter((a) => a.verdict === 'fixed' && !open.has(a.id)).reverse().slice(0, 10)
406
- .map((a) => ({ id: a.id, at: a.at, where: a.where, headline: a.headline, newProblems: (a.newProblems || []).length }));
407
-
408
- const recordings = countRecordings(root);
409
- const checks = CHECKS.map((c) => {
410
- let state;
411
- if (!c.available) state = 'not available';
412
- else if (!config.enabled[c.id]) state = 'switched off';
413
- else if (c.id === 'regression') state = !recordings ? 'no recordings yet' : !lastRun ? 'never run' : lastRun.failed ? 'problems found' : 'last run held';
414
- else if (c.id === 'frontend') state = !code ? 'never run' : (code.findings || []).length ? 'things to check' : 'nothing found';
415
- else if (c.id === 'data') {
416
- const ranNightly = nightly && nightly.ranAt && !nightly.stopped;
417
- state = !night && !ranNightly ? 'never run here'
418
- : findings.some((f) => f.check === 'data') ? 'problems found' : 'last run held';
419
- }
420
- return {
421
- id: c.id, label: c.label, available: c.available, enabled: config.enabled[c.id], state: state, what: c.what || '',
422
- nightly: c.id === 'data' ? nightlyLine(nightly) : null,
423
- // Runnable from the page: what needs nothing the page cannot supply. The
424
- // database check needs a connection string, which stays in the terminal.
425
- runnable: c.available && (c.id === 'frontend' || (c.id === 'regression' && recordings > 0)),
426
- };
427
- });
428
-
429
- const steps = startSteps(checks, {
430
- codeAt: code ? code.checkedAt : null,
431
- recordings: recordings,
432
- lastRunAt: lastRun ? lastRun.runAt : null,
433
- nightAt: [night ? nightWhen : null, nightly && nightly.ranAt].filter(Boolean).sort().pop() || null,
434
- });
435
-
436
- // Every look Kryptheon took - by `verify`, or by the dashboard's own watch -
437
- // newest first: what changed, what was run, and what came of it.
438
- const looks = readHistory(paths.changes, 15).reverse().map((e) => ({
439
- at: e.at,
440
- by: e.by || 'verify',
441
- first: !!e.first,
442
- files: (e.files || []).slice(0, 30),
443
- fileCount: (e.files || []).length,
444
- parts: e.parts || [],
445
- dependencies: e.dependencies || [],
446
- checks: e.checks || [],
447
- newFindings: e.newFindings || 0,
448
- fixed: e.fixed || 0,
449
- goneWithFile: e.goneWithFile || 0,
450
- }));
451
-
452
- return {
453
- project: { name: path.basename(root), root: root, storeDir: paths.dir },
454
- looks: looks,
455
- proven: proven,
456
- checks: checks,
457
- findings: findings,
458
- runs: history.slice().reverse().map((r) => ({ runAt: r.runAt, status: r.status, passed: r.passed, failed: r.failed, durationMs: r.durationMs })),
459
- recordings: recordings,
460
- steps: steps,
461
- next: nextAction(findings, steps),
462
- baselines: Object.keys(baselines).length,
463
- changes: recentChanges(root),
464
- // Part 3 is where a version gets verified. Until then this says so,
465
- // rather than leaving a blank that could be read either way.
466
- verification: { state: 'not verified', why: 'Kryptheon Verified is not built yet. No version of this project has been verified.' },
467
- };
468
- }
469
-
470
- /**
471
- * The server. Local only, and closed to other web pages:
472
- * - it listens on 127.0.0.1, never on the network;
473
- * - it answers only when the Host header is this address, so a page on some
474
- * other site cannot reach it through DNS rebinding;
475
- * - anything that changes something needs the token that is written into the
476
- * page this server hands out, which another site cannot read.
477
- */
478
- function createServer(root, options) {
479
- const opts = options || {};
480
- const env = opts.env || process.env;
481
- const token = crypto.randomBytes(24).toString('hex');
482
- const page = fs.readFileSync(path.join(__dirname, 'kryptheon-dashboard.html'), 'utf8');
483
- let port = 0;
484
- let running = false;
485
-
486
- const allowedHosts = () => new Set(['127.0.0.1:' + port, 'localhost:' + port]);
487
- const send = (res, code, type, body) => {
488
- res.writeHead(code, {
489
- 'content-type': type,
490
- 'cache-control': 'no-store',
491
- 'x-content-type-options': 'nosniff',
492
- 'referrer-policy': 'no-referrer',
493
- // The page loads nothing from anywhere else, and says so.
494
- 'content-security-policy': "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
495
- });
496
- res.end(body);
497
- };
498
- // Files served as they are from next to this one. A missing one is a
499
- // missing picture or font, never a stopped dashboard.
500
- const ASSETS = {
501
- '/logo.png': ['image/png', opts.logoFile || path.join(__dirname, 'kryptheon-logo.png')],
502
- '/fonts/archivo.woff2': ['font/woff2', path.join(__dirname, 'kryptheon-archivo.woff2')],
503
- '/fonts/spacemono.woff2': ['font/woff2', path.join(__dirname, 'kryptheon-spacemono.woff2')],
504
- };
505
- const json = (res, code, value) => send(res, code, 'application/json; charset=utf-8', JSON.stringify(value));
506
-
507
- const server = http.createServer((req, res) => {
508
- if (!allowedHosts().has(String(req.headers.host || ''))) return send(res, 403, 'text/plain', 'Forbidden');
509
- const url = new URL(req.url, 'http://127.0.0.1');
510
-
511
- if (req.method === 'GET' && url.pathname === '/') {
512
- return send(res, 200, 'text/html; charset=utf-8', page.replace('%%TOKEN%%', token));
513
- }
514
- if (req.method === 'GET' && url.pathname === '/dashboard.js') {
515
- return send(res, 200, 'text/javascript; charset=utf-8', fs.readFileSync(path.join(__dirname, 'kryptheon-dashboard-page.js'), 'utf8'));
516
- }
517
- if (req.method === 'GET' && Object.prototype.hasOwnProperty.call(ASSETS, url.pathname)) {
518
- const [type, file] = ASSETS[url.pathname];
519
- let body;
520
- try {
521
- body = fs.readFileSync(file);
522
- } catch (err) {
523
- return send(res, 404, 'text/plain', 'Not found');
524
- }
525
- return send(res, 200, type, body);
526
- }
527
- if (req.method === 'GET' && url.pathname === '/api/state') {
528
- return json(res, 200, buildState(root, env));
529
- }
530
- if (req.method === 'POST') {
531
- if (req.headers['x-kryptheon-token'] !== token) return json(res, 403, { ok: false, why: 'missing or wrong token' });
532
- let body = '';
533
- req.on('data', (chunk) => {
534
- body += chunk;
535
- if (body.length > 10000) req.destroy();
536
- });
537
- req.on('end', () => {
538
- let input;
539
- try {
540
- input = JSON.parse(body || '{}');
541
- } catch (err) {
542
- return json(res, 400, { ok: false, why: 'not JSON' });
543
- }
544
- if (url.pathname === '/api/check') {
545
- const r = setCheck(store.pathsFor(root, env), String(input.id || ''), input.on);
546
- return json(res, r.ok ? 200 : 400, r);
547
- }
548
- if (url.pathname === '/api/recheck' && typeof opts.recheck === 'function') {
549
- const id = String(input.id || '');
550
- if (!/^[0-9a-f]{8}$/.test(id)) return json(res, 400, { ok: false, why: 'not a finding id' });
551
- if (!buildState(root, env).findings.some((f) => f.id === id)) return json(res, 404, { ok: false, why: 'no open finding with that id' });
552
- if (running) return json(res, 409, { ok: false, why: 'a run is already going' });
553
- running = true;
554
- return Promise.resolve(opts.recheck(id)).finally(() => { running = false; }).then(
555
- (r) => json(res, r && r.verdict ? 200 : 500, Object.assign({ ok: !!(r && r.verdict) }, r || {})),
556
- (err) => json(res, 500, { ok: false, why: err.message }),
557
- );
558
- }
559
- if (url.pathname === '/api/run' && typeof opts.run === 'function') {
560
- const id = String(input.id || '');
561
- const check = buildState(root, env).checks.find((c) => c.id === id);
562
- if (!check || !check.runnable || !check.enabled) {
563
- return json(res, 400, { ok: false, why: (check ? check.label : '"' + id + '"') + ' cannot be run from here' });
564
- }
565
- // One run at a time: two browsers replaying the same flows against
566
- // one app would interfere, and the results would describe neither.
567
- if (running) return json(res, 409, { ok: false, why: 'a run is already going' });
568
- running = true;
569
- return Promise.resolve(opts.run(id)).finally(() => { running = false; }).then(
570
- (r) => json(res, r && r.ok ? 200 : 400, r || { ok: false }),
571
- (err) => json(res, 500, { ok: false, why: err.message }),
572
- );
573
- }
574
- return json(res, 404, { ok: false, why: 'no such action' });
575
- });
576
- return;
577
- }
578
- return send(res, 404, 'text/plain', 'Not found');
579
- });
580
-
581
- return {
582
- server: server,
583
- token: token,
584
- listen(wanted) {
585
- return new Promise((resolve, reject) => {
586
- server.once('error', reject);
587
- server.listen(wanted || 0, '127.0.0.1', () => {
588
- port = server.address().port;
589
- resolve(port);
590
- });
591
- });
592
- },
593
- close() {
594
- return new Promise((resolve) => server.close(() => resolve()));
595
- },
596
- };
597
- }
598
-
599
- module.exports = {
600
- CHECKS: CHECKS,
601
- buildState: buildState,
602
- flowPrompt: flowPrompt,
603
- startSteps: startSteps,
604
- nextAction: nextAction,
605
- readConfig: readConfig,
606
- setCheck: setCheck,
607
- createServer: createServer,
608
- };
1
+ // The local dashboard: one page on this machine that shows everything
2
+ // Kryptheon knows about one project, and switches its checks on and off.
3
+ //
4
+ // npx kryptheon (in a terminal) or npx kryptheon dashboard
5
+ //
6
+ // Everything here reads the project's store (kryptheon-store.js); nothing is
7
+ // kept anywhere else, and nothing leaves the machine. The server listens on
8
+ // 127.0.0.1 only.
9
+ //
10
+ // Two rules the page is built around:
11
+ //
12
+ // - A check that does not exist yet is shown as "not available". Never as
13
+ // passed, never as green, never counted. An empty row that looks like a
14
+ // clean one is the most expensive lie this product could tell.
15
+ // - Every value shown comes from a file that may hold text from the app being
16
+ // checked - a failing page's heading, a line of the app's own code. The page
17
+ // writes all of it with textContent, so the dashboard cannot become the XSS
18
+ // it is there to find. Its own code is read by kryptheon-code.js in its check.
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const http = require('http');
23
+ const crypto = require('crypto');
24
+ const { spawnSync } = require('child_process');
25
+ const store = require('./kryptheon-store.js');
26
+ const changes = require('./kryptheon-changes.js');
27
+
28
+ /**
29
+ * Every check the dashboard knows the name of, in the order the person asked
30
+ * for them. `available` is true only for what is actually built. What runs it
31
+ * and where its results come from is said here, once.
32
+ */
33
+ const CHECKS = [
34
+ { id: 'regression', label: 'Regression (recorded flows)', available: true,
35
+ what: 'Replays every recording in tests/ and compares each page with the last time it worked.' },
36
+ { id: 'frontend', label: 'Frontend: text inserted as HTML', available: true,
37
+ what: 'Reads your frontend code for values put into the page with innerHTML and similar (npx kryptheon code).' },
38
+ { id: 'data', label: 'Database: access rules, duplicates, orphans', available: true,
39
+ what: 'Attacks a copy of your Postgres/Supabase database (kryptheon-night). Paste the connection string in Start here; it is used for one run and never saved.' },
40
+ { id: 'build', label: 'Build', available: false },
41
+ { id: 'backend', label: 'Backend', available: false },
42
+ { id: 'api', label: 'API', available: false },
43
+ { id: 'integration', label: 'Integration', available: false },
44
+ { id: 'dependency', label: 'Dependencies', available: false },
45
+ { id: 'runtime', label: 'Runtime', available: false },
46
+ { id: 'performance', label: 'Performance', available: false },
47
+ { id: 'edge', label: 'Input and edge cases', available: false },
48
+ { id: 'reliability', label: 'Reliability', available: false },
49
+ { id: 'synthetic', label: 'Synthetic test users', available: false },
50
+ { id: 'eligibility', label: 'Age and eligibility rules', available: false },
51
+ ];
52
+
53
+ const CHECK_IDS = new Set(CHECKS.map((c) => c.id));
54
+
55
+ function readJson(file) {
56
+ try {
57
+ let raw = fs.readFileSync(file, 'utf8');
58
+ if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
59
+ return JSON.parse(raw);
60
+ } catch (err) {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function readHistory(file, keep) {
66
+ let raw;
67
+ try {
68
+ raw = fs.readFileSync(file, 'utf8');
69
+ } catch (err) {
70
+ return [];
71
+ }
72
+ const runs = [];
73
+ for (const line of raw.split('\n')) {
74
+ if (!line.trim()) continue;
75
+ try {
76
+ runs.push(JSON.parse(line));
77
+ } catch (err) {
78
+ /* a damaged line is skipped, not fatal */
79
+ }
80
+ }
81
+ return runs.slice(-keep);
82
+ }
83
+
84
+ /** Which checks are switched on. Available checks default to on; others can never be. */
85
+ function readConfig(paths) {
86
+ const saved = readJson(paths.config) || {};
87
+ const enabled = {};
88
+ for (const c of CHECKS) {
89
+ enabled[c.id] = c.available ? (saved.enabled && typeof saved.enabled[c.id] === 'boolean' ? saved.enabled[c.id] : true) : false;
90
+ }
91
+ return { enabled: enabled };
92
+ }
93
+
94
+ /**
95
+ * Switches one check on or off, and keeps it. Refuses what does not exist and
96
+ * what is not built: a "not available" check switched on would read as a
97
+ * promise that something is being checked.
98
+ */
99
+ function setCheck(paths, id, on) {
100
+ if (!CHECK_IDS.has(id)) return { ok: false, why: 'there is no check called "' + id + '"' };
101
+ const check = CHECKS.find((c) => c.id === id);
102
+ if (!check.available) return { ok: false, why: check.label + ' is not available yet, so it cannot be switched on' };
103
+ if (typeof on !== 'boolean') return { ok: false, why: 'on must be true or false' };
104
+ const config = readConfig(paths);
105
+ config.enabled[id] = on;
106
+ fs.mkdirSync(path.dirname(paths.config), { recursive: true });
107
+ fs.writeFileSync(paths.config, JSON.stringify(config, null, 2) + '\n', 'utf8');
108
+ return { ok: true, config: config };
109
+ }
110
+
111
+ /** What git says changed, read only. Null when this is not a git repository. */
112
+ function recentChanges(root) {
113
+ const git = (args) => {
114
+ const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 5000 });
115
+ return r.status === 0 ? String(r.stdout || '') : null;
116
+ };
117
+ const inside = git(['rev-parse', '--is-inside-work-tree']);
118
+ if (!inside || inside.trim() !== 'true') return null;
119
+ const log = git(['log', '-5', '--pretty=format:%h%x09%cI%x09%s']) || '';
120
+ const status = git(['status', '--porcelain']) || '';
121
+ return {
122
+ commits: log.split('\n').filter(Boolean).map((l) => {
123
+ const [hash, when, subject] = l.split('\t');
124
+ return { hash: hash, when: when, subject: subject };
125
+ }),
126
+ uncommitted: status.split('\n').filter(Boolean).map((l) => ({ state: l.slice(0, 2).trim(), file: l.slice(3) })),
127
+ };
128
+ }
129
+
130
+ /** " - the risky part: x" for a frontend finding that names one, else "". */
131
+ function riskyPartsLine(f) {
132
+ const parts = (f.parts || []).filter((p) => p && p.text && p.text !== f.expression);
133
+ if (!parts.length) return '';
134
+ return ' - the risky part' + (parts.length === 1 ? ': ' : 's: ') + parts.map((p) => p.text).join(', ');
135
+ }
136
+
137
+ /** When this flow last passed, from the run history, or null. */
138
+ function lastPassOf(history, title) {
139
+ for (let i = history.length - 1; i >= 0; i--) {
140
+ const t = (history[i].tests || []).find((x) => x.title === title);
141
+ if (t && t.status === 'passed') return history[i].runAt;
142
+ }
143
+ return null;
144
+ }
145
+
146
+ /**
147
+ * The fix prompt for a broken recorded flow, from what the run kept: what was
148
+ * seen, what was expected, where, the evidence, and how to see it again. Only
149
+ * what the run recorded is said; nothing is guessed.
150
+ */
151
+ function flowPrompt(t, lastPass) {
152
+ const f = t.failure || {};
153
+ const o = f.observations || {};
154
+ const file = String(f.file || t.specFile || 'tests').split('\\').join('/');
155
+ const extra = String(f.rawMessage || '').split(/\r?\n/).slice(1)
156
+ .map((l) => l.trim()).filter((l) => l && !/^\s*at\s/.test(l) && l.length < 200).slice(0, 6);
157
+ const lines = [
158
+ 'My app has a broken flow. Please fix the app - and nothing else.',
159
+ '',
160
+ 'Kryptheon replayed the recorded flow "' + t.title + '" (' + file + (f.line ? ', line ' + f.line : '') + ') and it failed.',
161
+ '',
162
+ 'Observed: ' + (f.plainLanguage || 'the replay failed'),
163
+ ].concat(extra.map((l) => ' ' + l), [
164
+ 'Expected: the same result as ' + (lastPass ? 'the last time it passed (' + lastPass + ')' : 'when it was recorded') + '.',
165
+ ]);
166
+ if (o.url) lines.push('Where: the browser was on ' + o.url + (o.pageShowed ? ', and the page showed ' + o.pageShowed : '') + '.');
167
+ if (f.locator) lines.push('Step that failed: ' + f.locator);
168
+ if (o.failedRequests && o.failedRequests.length) lines.push('Failed requests: ' + o.failedRequests.slice(0, 5).map((r) => (r.method || 'GET') + ' ' + (r.path || r.url) + ' -> ' + r.status).join('; '));
169
+ if (o.consoleErrors && o.consoleErrors.length) lines.push('Console errors: ' + o.consoleErrors.slice(0, 3).join(' | '));
170
+ if (f.screenshot) lines.push('Evidence: a screenshot of the failure at ' + f.screenshot);
171
+ lines.push('');
172
+ lines.push('To see it yourself: open ' + (o.url || 'the app') + ' and repeat the steps in ' + file + ', or run npx kryptheon check');
173
+ lines.push('');
174
+ lines.push('Fix the app so this flow works again. Do not edit the recording to make it pass. If the new behaviour ' +
175
+ 'is intended, say so instead of changing code - it can be accepted with npx kryptheon accept "' + t.title + '"');
176
+ return lines.join('\n');
177
+ }
178
+
179
+ function countRecordings(root) {
180
+ try {
181
+ return fs.readdirSync(path.join(root, 'tests')).filter((n) => /\.(spec|test)\.[cm]?[jt]s$/.test(n)).length;
182
+ } catch (err) {
183
+ return 0;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * The order someone new should go in, one step at a time. A step is "done"
189
+ * only when it has actually run and left a record - never because nothing was
190
+ * found - and a switched-off check says "off", so an empty list is never read
191
+ * as a clean one.
192
+ */
193
+ // How often a step is done. Said on every step and button, because the two
194
+ // kinds are done at different moments and mixing them up is how a check stops
195
+ // being run: "one time" is setting up, "after every change" is the habit.
196
+ const ONE_TIME = 'one time';
197
+ const EVERY_CHANGE = 'after every change';
198
+
199
+ function startSteps(checks, seen) {
200
+ const on = (id) => (checks.find((c) => c.id === id) || {}).enabled;
201
+ const nightly = seen.nightly || null;
202
+ return [
203
+ {
204
+ id: 'frontend',
205
+ title: 'Read your frontend code',
206
+ when: EVERY_CHANGE,
207
+ why: 'Finds places where text could be put into your page as HTML. Needs nothing from you.',
208
+ state: !on('frontend') ? 'off' : seen.codeAt ? 'done' : 'todo',
209
+ at: seen.codeAt,
210
+ action: on('frontend') ? { kind: 'run', check: 'frontend', label: seen.codeAt ? 'Read it again' : 'Read my code', command: RUN_COMMAND.frontend } : null,
211
+ },
212
+ {
213
+ id: 'record',
214
+ title: 'Record your main flows',
215
+ when: ONE_TIME,
216
+ whenMore: 'again only when that flow changes',
217
+ // The lesson of a test where 26 bugs were planted before recording and
218
+ // none was caught: what is recorded is taken as right.
219
+ why: 'Sign up, log in, pay - do each once while Kryptheon watches. Record while your app works: what it sees is taken as right, so a bug already there when you record becomes part of "working". Start your app first, then put its address here.',
220
+ state: seen.recordings ? 'done' : 'todo',
221
+ at: null,
222
+ count: seen.recordings,
223
+ action: { kind: 'record', label: seen.recordings ? 'Record another' : 'Record', command: 'npx kryptheon record <your app address>' },
224
+ },
225
+ {
226
+ id: 'regression',
227
+ title: 'Replay them',
228
+ when: EVERY_CHANGE,
229
+ why: 'Runs every recording against your app and compares each page with the last time it worked. Start your app first.',
230
+ state: !on('regression') ? 'off' : !seen.recordings ? 'waiting' : seen.lastRunAt ? 'done' : 'todo',
231
+ at: seen.lastRunAt,
232
+ action: on('regression') && seen.recordings ? { kind: 'run', check: 'regression', label: seen.lastRunAt ? 'Replay again' : 'Replay now', command: RUN_COMMAND.regression } : null,
233
+ },
234
+ {
235
+ id: 'connect',
236
+ title: 'Connect your database',
237
+ when: ONE_TIME,
238
+ why: 'Paste your connection string - the steps are next to the box. It goes only to this dashboard on your machine and to kryptheon-night for the length of one run. It is never saved and never shown again; reload the page and it is gone. Try it on a test project first.',
239
+ state: !on('data') ? 'off' : seen.nightAt ? 'done' : 'todo',
240
+ at: null,
241
+ action: on('data') ? { kind: 'connect' } : null,
242
+ },
243
+ {
244
+ id: 'data',
245
+ title: 'Check your database',
246
+ when: EVERY_CHANGE,
247
+ why: 'Attacks a copy inside your Postgres or Supabase database and reports what got in; your own rows are not touched. You are shown exactly what it will do, and it waits for your yes.',
248
+ state: !on('data') ? 'off' : seen.nightAt ? 'done' : 'todo',
249
+ at: seen.nightAt,
250
+ action: on('data') ? { kind: 'db', action: 'scan', label: 'Check my database', command: DB_ACTIONS.scan.command } : null,
251
+ },
252
+ {
253
+ id: 'nightly',
254
+ title: 'Check it every night',
255
+ when: ONE_TIME,
256
+ // Offered, never pressed for: an app can be checked well without it,
257
+ // so it is not what "do this next" points at.
258
+ optional: true,
259
+ why: 'Sets the same check up inside your database, to run by itself every night, and keeps the answer there for this page. It stays until you remove it.',
260
+ state: !on('data') ? 'off' : nightly && nightly.installed && nightly.active ? 'done' : 'todo',
261
+ at: nightly && nightly.ranAt ? nightly.ranAt : null,
262
+ action: on('data') ? { kind: 'nightly', installed: Boolean(nightly && nightly.installed), at: nightly ? nightly.scheduled || null : null } : null,
263
+ },
264
+ ];
265
+ }
266
+
267
+ /**
268
+ * The one thing to do now. Open confirmed problems come before everything; then
269
+ * the first step not taken; then what only needs a look; then nothing.
270
+ */
271
+ function nextAction(findings, steps) {
272
+ const confirmed = findings.filter((f) => f.status === 'confirmed').length;
273
+ const toVerify = findings.length - confirmed;
274
+ if (confirmed) {
275
+ return { kind: 'fix', view: 'findings', count: confirmed,
276
+ title: confirmed === 1 ? 'Fix 1 confirmed problem' : 'Fix ' + confirmed + ' confirmed problems' };
277
+ }
278
+ const step = steps.find((s) => s.state === 'todo' && !s.optional);
279
+ if (step) return { kind: 'step', step: step.id, title: step.title };
280
+ if (toVerify) {
281
+ return { kind: 'verify', view: 'findings', count: toVerify,
282
+ title: toVerify === 1 ? 'Look at 1 thing to verify' : 'Look at ' + toVerify + ' things to verify' };
283
+ }
284
+ return { kind: 'quiet', title: 'Nothing open' };
285
+ }
286
+
287
+ /**
288
+ * One sentence about the nightly run inside the database, or null when
289
+ * kryptheon-night has never been asked. Its four states are four different
290
+ * answers, and none of the other three may read as "nothing found": not set
291
+ * up, set up and never run, set up with nothing scheduled, and stopped.
292
+ */
293
+ function nightlyLine(nightly) {
294
+ if (!nightly) return null;
295
+ // The database runs on UTC. Found on a real read-back: "03:00" was 08:30
296
+ // in India, and nothing said so - so both are said, and the moment it was
297
+ // read is said in this computer's own time.
298
+ const local = (iso) => new Date(iso).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
299
+ const day = (iso) => String(iso || '').slice(0, 16).replace('T', ' ') + ' UTC (' + local(iso) + ' your time)';
300
+ const read = ' (read ' + local(nightly.readAt) + ')';
301
+ if (!nightly.installed) return 'Nightly run in your database: not set up. Press "Set up nightly check" in Start here.' + read;
302
+ const idle = nightly.active ? '' : ' Nothing is scheduled, so it is not running - press "Set it up again" in Start here.';
303
+ if (!nightly.ranAt) return 'Nightly run in your database: set up, and it has not run yet.' + idle + read;
304
+ const when = 'Nightly run in your database, ' + day(nightly.ranAt) + ': ';
305
+ if (nightly.stopped) return when + 'it could not check - ' + nightly.stopped + idle + read;
306
+ // A function anyone can call is a thing to check, not a problem proven -
307
+ // counted apart, the way the terminal counts it.
308
+ const n = (nightly.findings || []).filter((f) => f.status !== 'verification required').length;
309
+ const toCheck = (nightly.findings || []).length - n;
310
+ const skipped = (nightly.notChecked || []).length;
311
+ return when + (n ? n + (n === 1 ? ' problem found.' : ' problems found.')
312
+ : 'nothing got through ' + (nightly.attacksRun || 0) + ' attacks.') +
313
+ (toCheck ? ' ' + toCheck + (toCheck === 1 ? ' thing' : ' things') + ' to check.' : '') +
314
+ (skipped ? ' ' + skipped + ' ' + (skipped === 1 ? 'part was' : 'parts were') + ' not tested.' : '') + idle + read;
315
+ }
316
+
317
+ /**
318
+ * Everything the page shows, from the store alone. Findings from every check
319
+ * are put into one list, each carrying the same fields - severity, status,
320
+ * evidence, confidence, where, what, and the fix - so the page can show them
321
+ * the same way whichever check found them.
322
+ */
323
+ function buildState(root, env) {
324
+ const paths = store.pathsFor(root, env);
325
+ const config = readConfig(paths);
326
+ const history = readHistory(paths.history, 20);
327
+ const baselines = readJson(paths.baselines) || {};
328
+ const code = readJson(paths.codeFindings);
329
+ const night = readJson(paths.nightLast);
330
+ // What the nightly run inside the database last said, kept by
331
+ // kryptheon-night whenever it connects. Not the same answer as night:
332
+ // that one is the scan last run from this terminal.
333
+ const nightly = readJson(paths.nightNightly);
334
+
335
+ const findings = [];
336
+
337
+ // Recorded flows: the last run's broken tests are the findings. They were
338
+ // replayed in a real browser, so they are confirmed at runtime.
339
+ const lastRun = history.length ? history[history.length - 1] : null;
340
+ if (lastRun && config.enabled.regression) {
341
+ for (const t of lastRun.tests || []) {
342
+ if (t.status !== 'failed' && t.status !== 'timedOut') continue;
343
+ findings.push({
344
+ check: 'regression',
345
+ severity: 'HIGH',
346
+ status: 'confirmed',
347
+ evidence: 'runtime confirmed',
348
+ confidence: 'high',
349
+ where: String((t.failure && t.failure.file) || t.specFile || 'tests').split('\\').join('/') +
350
+ (t.failure && t.failure.line ? ':' + t.failure.line : ''),
351
+ headline: 'The recorded flow "' + t.title + '" broke.',
352
+ detail: (t.failure && t.failure.plainLanguage) || '',
353
+ fixPrompt: flowPrompt(t, lastPassOf(history, t.title)),
354
+ when: lastRun.runAt,
355
+ });
356
+ }
357
+ }
358
+
359
+ if (code && config.enabled.frontend) {
360
+ for (const f of code.findings || []) {
361
+ findings.push({
362
+ check: 'frontend',
363
+ severity: f.severity,
364
+ status: f.status,
365
+ evidence: f.evidence,
366
+ confidence: f.confidence,
367
+ where: f.file + ':' + f.line,
368
+ headline: f.headline,
369
+ // The part of a long template that decides it, so the person reading
370
+ // it looks at one value instead of sixty lines of markup.
371
+ detail: f.sink + ': ' + f.expression + riskyPartsLine(f),
372
+ fixPrompt: f.fixPrompt,
373
+ when: code.checkedAt,
374
+ });
375
+ }
376
+ }
377
+
378
+ let nightWhen = null;
379
+ try {
380
+ nightWhen = fs.statSync(paths.nightLast).mtime.toISOString();
381
+ } catch (err) {
382
+ /* never run */
383
+ }
384
+ if (night && config.enabled.data) {
385
+ for (const f of night.findings || []) {
386
+ findings.push({
387
+ check: 'data',
388
+ severity: f.severity,
389
+ status: f.status || 'confirmed',
390
+ evidence: (f.status || 'confirmed') === 'confirmed' ? 'runtime confirmed' : 'code analysis',
391
+ confidence: (f.status || 'confirmed') === 'confirmed' ? 'high' : 'medium',
392
+ where: f.table + (f.column ? '.' + f.column : ''),
393
+ headline: f.headline,
394
+ detail: f.body || '',
395
+ fixPrompt: f.fixPrompt || '',
396
+ when: nightWhen,
397
+ });
398
+ }
399
+ }
400
+
401
+ // The nightly run's findings, said as coming from it. One both answers
402
+ // share is shown once, with the newer date - the same problem found twice
403
+ // is still one problem to fix.
404
+ if (nightly && nightly.ranAt && config.enabled.data) {
405
+ for (const f of nightly.findings || []) {
406
+ const item = {
407
+ check: 'data',
408
+ from: 'nightly',
409
+ severity: f.severity,
410
+ status: f.status || 'confirmed',
411
+ evidence: (f.status || 'confirmed') === 'confirmed' ? 'runtime confirmed' : 'code analysis',
412
+ confidence: (f.status || 'confirmed') === 'confirmed' ? 'high' : 'medium',
413
+ where: f.table + (f.column ? '.' + f.column : ''),
414
+ headline: f.headline,
415
+ detail: f.body || '',
416
+ fixPrompt: f.fixPrompt || '',
417
+ when: nightly.ranAt,
418
+ };
419
+ const same = findings.find((o) => o.check === 'data' && changes.findingKey(o) === changes.findingKey(item));
420
+ if (!same) findings.push(item);
421
+ else {
422
+ same.from = 'both';
423
+ if (!same.when || item.when > same.when) same.when = item.when;
424
+ }
425
+ }
426
+ }
427
+
428
+ const rank = { 'HIGH CRITICAL': 0, CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
429
+ findings.sort((a, b) => (rank[a.severity] ?? 9) - (rank[b.severity] ?? 9) ||
430
+ (a.status === 'confirmed' ? 0 : 1) - (b.status === 'confirmed' ? 0 : 1));
431
+
432
+ // Every finding gets a short id and the last word of its fix prompt: how to
433
+ // prove the fix. Added here, where the loop lives, so every check's prompt
434
+ // ends the same way and the detection code itself stays untouched.
435
+ const attempts = readHistory(paths.fixes, 500);
436
+ for (const f of findings) {
437
+ f.id = changes.findingId(f);
438
+ f.attempts = attempts.filter((a) => a.id === f.id).map((a) => ({ at: a.at, verdict: a.verdict, why: a.why || '', newProblems: (a.newProblems || []).length }));
439
+ if (f.fixPrompt) {
440
+ f.fixPrompt += '\n\nWhen you have fixed it, prove it: run npx kryptheon recheck ' + f.id +
441
+ ' (or press Re-check on this finding in the Kryptheon dashboard). It is fixed only when that says FIXED.';
442
+ }
443
+ }
444
+ // Fixes that were proven, newest first, while their finding is still gone.
445
+ const open = new Set(findings.map((f) => f.id));
446
+ const proven = attempts.filter((a) => a.verdict === 'fixed' && !open.has(a.id)).reverse().slice(0, 10)
447
+ .map((a) => ({ id: a.id, at: a.at, where: a.where, headline: a.headline, newProblems: (a.newProblems || []).length }));
448
+
449
+ const recordings = countRecordings(root);
450
+ const checks = CHECKS.map((c) => {
451
+ let state;
452
+ if (!c.available) state = 'not available';
453
+ else if (!config.enabled[c.id]) state = 'switched off';
454
+ else if (c.id === 'regression') state = !recordings ? 'no recordings yet' : !lastRun ? 'never run' : lastRun.failed ? 'problems found' : 'last run held';
455
+ else if (c.id === 'frontend') state = !code ? 'never run' : (code.findings || []).length ? 'things to check' : 'nothing found';
456
+ else if (c.id === 'data') {
457
+ const ranNightly = nightly && nightly.ranAt && !nightly.stopped;
458
+ state = !night && !ranNightly ? 'never run here'
459
+ : findings.some((f) => f.check === 'data') ? 'problems found' : 'last run held';
460
+ }
461
+ return {
462
+ id: c.id, label: c.label, available: c.available, enabled: config.enabled[c.id], state: state, what: c.what || '',
463
+ nightly: c.id === 'data' ? nightlyLine(nightly) : null,
464
+ // Runnable from the page: what needs nothing the page cannot supply. The
465
+ // database check needs a connection string, which stays in the terminal.
466
+ runnable: c.available && (c.id === 'frontend' || (c.id === 'regression' && recordings > 0)),
467
+ };
468
+ });
469
+
470
+ const steps = startSteps(checks, {
471
+ codeAt: code ? code.checkedAt : null,
472
+ recordings: recordings,
473
+ lastRunAt: lastRun ? lastRun.runAt : null,
474
+ nightAt: [night ? nightWhen : null, nightly && nightly.ranAt].filter(Boolean).sort().pop() || null,
475
+ nightly: nightly,
476
+ });
477
+
478
+ // Every look Kryptheon took - by `verify`, or by the dashboard's own watch -
479
+ // newest first: what changed, what was run, and what came of it.
480
+ const looks = readHistory(paths.changes, 15).reverse().map((e) => ({
481
+ at: e.at,
482
+ by: e.by || 'verify',
483
+ first: !!e.first,
484
+ files: (e.files || []).slice(0, 30),
485
+ fileCount: (e.files || []).length,
486
+ parts: e.parts || [],
487
+ dependencies: e.dependencies || [],
488
+ checks: e.checks || [],
489
+ newFindings: e.newFindings || 0,
490
+ fixed: e.fixed || 0,
491
+ goneWithFile: e.goneWithFile || 0,
492
+ }));
493
+
494
+ return {
495
+ project: { name: path.basename(root), root: root, storeDir: paths.dir },
496
+ looks: looks,
497
+ proven: proven,
498
+ checks: checks,
499
+ findings: findings,
500
+ runs: history.slice().reverse().map((r) => ({ runAt: r.runAt, status: r.status, passed: r.passed, failed: r.failed, durationMs: r.durationMs })),
501
+ recordings: recordings,
502
+ steps: steps,
503
+ next: nextAction(findings, steps),
504
+ baselines: Object.keys(baselines).length,
505
+ changes: recentChanges(root),
506
+ // Part 3 is where a version gets verified. Until then this says so,
507
+ // rather than leaving a blank that could be read either way.
508
+ verification: { state: 'not verified', why: 'Kryptheon Verified is not built yet. No version of this project has been verified.' },
509
+ };
510
+ }
511
+
512
+ /* --------------------------------------------------------------------------
513
+ Jobs: what the page starts, one at a time, with its progress and its end.
514
+ -------------------------------------------------------------------------- */
515
+
516
+ // What each run is called on the page, and the command that does the same in
517
+ // a terminal - shown under every button, for when the page cannot be used.
518
+ const RUN_TITLE = { frontend: 'Reading your code', regression: 'Replaying your recordings' };
519
+ const RUN_COMMAND = { frontend: 'npx kryptheon code', regression: 'npx kryptheon check' };
520
+ const DB_ACTIONS = {
521
+ scan: { title: 'Checking your database', command: 'npx kryptheon-night' },
522
+ install: { title: 'Setting up the nightly check', command: 'npx kryptheon-night install' },
523
+ night: { title: 'Reading last night\'s result', command: 'npx kryptheon-night night' },
524
+ uninstall: { title: 'Removing the nightly check', command: 'npx kryptheon-night uninstall' },
525
+ recheck: { title: 'Re-checking in your database', command: 'npx kryptheon-night' },
526
+ };
527
+
528
+ /**
529
+ * What a fresh database check says about one database finding. Fixed only
530
+ * when the check ran, the finding is gone, and nothing about its table went
531
+ * untested - a table that could not be tested loses its findings too, and
532
+ * that is not a fix. A finding still shown only from the nightly run is the
533
+ * nightly run's older answer, not this one's.
534
+ */
535
+ function databaseVerdict(target, before, after, ran, saved) {
536
+ const tableOf = (where) => String(where || '').split('.')[0];
537
+ let verdict;
538
+ let why = '';
539
+ const untested = ((saved && saved.notChecked) || []).find((n) => tableOf(n.table) === tableOf(target.where));
540
+ if (!ran || !ran.ok) {
541
+ verdict = 'could not confirm';
542
+ why = (ran && ran.why) || 'the database check could not run';
543
+ } else if (after.some((f) => f.id === target.id && f.from !== 'nightly')) {
544
+ verdict = 'still open';
545
+ } else if (untested) {
546
+ verdict = 'could not confirm';
547
+ why = 'part of ' + tableOf(target.where) + ' could not be tested this time: ' + untested.why;
548
+ } else {
549
+ verdict = 'fixed';
550
+ }
551
+ const had = new Set(before.map((f) => f.id));
552
+ return {
553
+ id: target.id,
554
+ at: new Date().toISOString(),
555
+ check: 'data',
556
+ where: target.where,
557
+ headline: target.headline,
558
+ verdict: verdict,
559
+ why: why,
560
+ newProblems: after.filter((f) => !had.has(f.id) && f.check === 'data').map((f) => ({
561
+ id: f.id, check: f.check, severity: f.severity, status: f.status, where: f.where, headline: f.headline,
562
+ })),
563
+ };
564
+ }
565
+ const JOB_LINES = 400;
566
+
567
+ /**
568
+ * The address to record, or null. Refused: anything that is not one line,
569
+ * anything that could be read as an option by the command it is passed to,
570
+ * and anything with a scheme other than http(s).
571
+ */
572
+ function appAddress(value) {
573
+ if (typeof value !== 'string') return null;
574
+ const text = value.trim();
575
+ if (!text || text.length > 2000 || /[\s\x00-\x1f]/.test(text) || text.startsWith('-')) return null;
576
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) return /^https?:\/\/[^/]/i.test(text) ? text : null;
577
+ return /^[a-z0-9.-]+(:\d{1,5})?(\/\S*)?$/i.test(text) ? text : null;
578
+ }
579
+
580
+ /** The connection string as given, or null when there is none or it is absurd. */
581
+ function connectionFrom(value) {
582
+ if (typeof value !== 'string') return null;
583
+ const text = value.trim();
584
+ return text && text.length <= 4000 ? text : null;
585
+ }
586
+
587
+ /**
588
+ * Every form in which a connection string could leak back out: the whole
589
+ * string, without the quotes it may have been pasted with, and its password
590
+ * both as typed and decoded. Longest first, so a password inside the whole
591
+ * string is never left half-hidden.
592
+ */
593
+ function secretsOf(connection) {
594
+ const raw = String(connection || '').trim();
595
+ if (!raw) return [];
596
+ const found = [raw, raw.replace(/^["']|["']$/g, '')];
597
+ try {
598
+ const password = new URL(found[1]).password;
599
+ if (password) {
600
+ found.push(password);
601
+ try { found.push(decodeURIComponent(password)); } catch (err) { /* kept as typed */ }
602
+ }
603
+ } catch (err) {
604
+ /* not a URL: the string itself is still hidden */
605
+ }
606
+ return Array.from(new Set(found.filter((s) => s.length >= 4))).sort((a, b) => b.length - a.length);
607
+ }
608
+
609
+ function hideSecrets(text, secrets) {
610
+ let out = String(text);
611
+ for (const s of secrets || []) out = out.split(s).join('[hidden]');
612
+ return out;
613
+ }
614
+
615
+ /** The same, through every string in a value. */
616
+ function hideSecretsIn(value, secrets) {
617
+ if (!secrets || !secrets.length) return value;
618
+ if (typeof value === 'string') return hideSecrets(value, secrets);
619
+ if (Array.isArray(value)) return value.map((v) => hideSecretsIn(v, secrets));
620
+ if (value && typeof value === 'object') {
621
+ const out = {};
622
+ for (const k of Object.keys(value)) out[k] = hideSecretsIn(value[k], secrets);
623
+ return out;
624
+ }
625
+ return value;
626
+ }
627
+
628
+ /**
629
+ * The one job that may run, and the last one that did. Its lines are what the
630
+ * work said as it went, scrubbed of any secret it was given before they are
631
+ * kept; when it ends, the secrets are let go.
632
+ */
633
+ function createJobs() {
634
+ let current = null;
635
+ let count = 0;
636
+ const view = (job) => job && {
637
+ id: job.id, kind: job.kind, title: job.title, command: job.command,
638
+ startedAt: job.startedAt, endedAt: job.endedAt, running: job.running,
639
+ lines: job.lines.slice(), result: job.result,
640
+ };
641
+ return {
642
+ get: () => view(current),
643
+ busy: () => Boolean(current && current.running),
644
+ start(kind, title, command, work, givenSecrets) {
645
+ if (current && current.running) return null;
646
+ let secrets = givenSecrets || [];
647
+ const job = {
648
+ id: ++count, kind: kind, title: title, command: command || '',
649
+ startedAt: new Date().toISOString(), endedAt: null, running: true, lines: [], result: null,
650
+ };
651
+ current = job;
652
+ const say = (text) => {
653
+ if (!job.running) return;
654
+ // Colour codes are for terminals; on the page they are noise.
655
+ const clean = hideSecrets(String(text == null ? '' : text), secrets).replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
656
+ for (const line of clean.split(/\r?\n/)) job.lines.push(line.slice(0, 500));
657
+ if (job.lines.length > JOB_LINES) job.lines.splice(0, job.lines.length - JOB_LINES);
658
+ };
659
+ const finish = (result) => {
660
+ job.result = hideSecretsIn(result && typeof result === 'object' ? result : { ok: false, why: 'the run gave no answer' }, secrets);
661
+ job.running = false;
662
+ job.endedAt = new Date().toISOString();
663
+ secrets = null;
664
+ };
665
+ Promise.resolve()
666
+ .then(() => work(say))
667
+ .then(finish, (err) => finish({ ok: false, why: err && err.message ? err.message : String(err) }));
668
+ return view(job);
669
+ },
670
+ };
671
+ }
672
+
673
+ /**
674
+ * The server. Local only, and closed to other web pages:
675
+ * - it listens on 127.0.0.1, never on the network;
676
+ * - it answers only when the Host header is this address, so a page on some
677
+ * other site cannot reach it through DNS rebinding;
678
+ * - anything that changes something needs the token that is written into the
679
+ * page this server hands out, which another site cannot read.
680
+ */
681
+ function createServer(root, options) {
682
+ const opts = options || {};
683
+ const env = opts.env || process.env;
684
+ const token = crypto.randomBytes(24).toString('hex');
685
+ const page = fs.readFileSync(path.join(__dirname, 'kryptheon-dashboard.html'), 'utf8');
686
+ let port = 0;
687
+ const jobs = createJobs();
688
+
689
+ const allowedHosts = () => new Set(['127.0.0.1:' + port, 'localhost:' + port]);
690
+ const send = (res, code, type, body) => {
691
+ res.writeHead(code, {
692
+ 'content-type': type,
693
+ 'cache-control': 'no-store',
694
+ 'x-content-type-options': 'nosniff',
695
+ 'referrer-policy': 'no-referrer',
696
+ // The page loads nothing from anywhere else, and says so.
697
+ 'content-security-policy': "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
698
+ });
699
+ res.end(body);
700
+ };
701
+ // Files served as they are from next to this one. A missing one is a
702
+ // missing picture or font, never a stopped dashboard.
703
+ const ASSETS = {
704
+ '/logo.png': ['image/png', opts.logoFile || path.join(__dirname, 'kryptheon-logo.png')],
705
+ '/fonts/archivo.woff2': ['font/woff2', path.join(__dirname, 'kryptheon-archivo.woff2')],
706
+ '/fonts/spacemono.woff2': ['font/woff2', path.join(__dirname, 'kryptheon-spacemono.woff2')],
707
+ };
708
+ const json = (res, code, value) => send(res, code, 'application/json; charset=utf-8', JSON.stringify(value));
709
+
710
+ const server = http.createServer((req, res) => {
711
+ if (!allowedHosts().has(String(req.headers.host || ''))) return send(res, 403, 'text/plain', 'Forbidden');
712
+ const url = new URL(req.url, 'http://127.0.0.1');
713
+
714
+ if (req.method === 'GET' && url.pathname === '/') {
715
+ return send(res, 200, 'text/html; charset=utf-8', page.replace('%%TOKEN%%', token));
716
+ }
717
+ if (req.method === 'GET' && url.pathname === '/dashboard.js') {
718
+ return send(res, 200, 'text/javascript; charset=utf-8', fs.readFileSync(path.join(__dirname, 'kryptheon-dashboard-page.js'), 'utf8'));
719
+ }
720
+ if (req.method === 'GET' && Object.prototype.hasOwnProperty.call(ASSETS, url.pathname)) {
721
+ const [type, file] = ASSETS[url.pathname];
722
+ let body;
723
+ try {
724
+ body = fs.readFileSync(file);
725
+ } catch (err) {
726
+ return send(res, 404, 'text/plain', 'Not found');
727
+ }
728
+ return send(res, 200, type, body);
729
+ }
730
+ if (req.method === 'GET' && url.pathname === '/api/state') {
731
+ return json(res, 200, buildState(root, env));
732
+ }
733
+ if (req.method === 'GET' && url.pathname === '/api/job') {
734
+ return json(res, 200, { job: jobs.get() });
735
+ }
736
+ if (req.method === 'POST') {
737
+ if (req.headers['x-kryptheon-token'] !== token) return json(res, 403, { ok: false, why: 'missing or wrong token' });
738
+ let body = '';
739
+ req.on('data', (chunk) => {
740
+ body += chunk;
741
+ if (body.length > 10000) req.destroy();
742
+ });
743
+ req.on('end', () => {
744
+ let input;
745
+ try {
746
+ input = JSON.parse(body || '{}');
747
+ } catch (err) {
748
+ return json(res, 400, { ok: false, why: 'not JSON' });
749
+ }
750
+ if (url.pathname === '/api/check') {
751
+ const r = setCheck(store.pathsFor(root, env), String(input.id || ''), input.on);
752
+ return json(res, r.ok ? 200 : 400, r);
753
+ }
754
+ // Everything below starts a job: it answers at once with the job, and
755
+ // the page follows it on /api/job. One at a time - two browsers
756
+ // replaying against one app, or two attacks on one database, would
757
+ // interfere, and the results would describe neither.
758
+ const begin = (kind, title, command, work, secrets) => {
759
+ const job = jobs.start(kind, title, command, work, secrets);
760
+ return job ? json(res, 202, { ok: true, job: job }) : json(res, 409, { ok: false, why: 'a run is already going', job: jobs.get() });
761
+ };
762
+ if (url.pathname === '/api/recheck' && typeof opts.recheck === 'function') {
763
+ const id = String(input.id || '');
764
+ if (!/^[0-9a-f]{8}$/.test(id)) return json(res, 400, { ok: false, why: 'not a finding id' });
765
+ const open = buildState(root, env).findings.find((f) => f.id === id);
766
+ if (!open) return json(res, 404, { ok: false, why: 'no open finding with that id' });
767
+ return begin('recheck', 'Re-checking: ' + open.headline, 'npx kryptheon recheck ' + id, (say) => opts.recheck(id, say));
768
+ }
769
+ if (url.pathname === '/api/run' && typeof opts.run === 'function') {
770
+ const id = String(input.id || '');
771
+ const check = buildState(root, env).checks.find((c) => c.id === id);
772
+ if (!check || !check.runnable || !check.enabled) {
773
+ return json(res, 400, { ok: false, why: (check ? check.label : '"' + id + '"') + ' cannot be run from here' });
774
+ }
775
+ return begin('run', RUN_TITLE[id] || check.label, RUN_COMMAND[id] || '', (say) => opts.run(id, say));
776
+ }
777
+ if (url.pathname === '/api/record' && typeof opts.record === 'function') {
778
+ const address = appAddress(input.url);
779
+ if (!address) return json(res, 400, { ok: false, why: 'that is not a web address - it should look like http://localhost:3000' });
780
+ return begin('record', 'Recording ' + address, 'npx kryptheon record ' + address, (say) => opts.record(address, say));
781
+ }
782
+ if (url.pathname === '/api/db' && typeof opts.db === 'function') {
783
+ const action = String(input.action || '');
784
+ if (!DB_ACTIONS[action]) return json(res, 400, { ok: false, why: 'no database action called "' + action + '"' });
785
+ const connection = connectionFrom(input.connection);
786
+ if (!connection) return json(res, 400, { ok: false, why: 'paste your connection string first' });
787
+ if (action === 'recheck') {
788
+ // One database finding, re-checked: the check runs again and that
789
+ // finding is judged, the way `npx kryptheon recheck` judges the rest.
790
+ const id = String(input.id || '');
791
+ if (!/^[0-9a-f]{8}$/.test(id)) return json(res, 400, { ok: false, why: 'not a finding id' });
792
+ const target = buildState(root, env).findings.find((f) => f.id === id && f.check === 'data');
793
+ if (!target) return json(res, 404, { ok: false, why: 'no open database finding with that id' });
794
+ return begin('db', 'Re-checking: ' + target.headline, DB_ACTIONS.recheck.command, (say) => {
795
+ const before = buildState(root, env).findings;
796
+ return Promise.resolve(opts.db('scan', connection, say)).then((ran) => {
797
+ const attempt = databaseVerdict(target, before, buildState(root, env).findings, ran, readJson(store.pathsFor(root, env).nightLast));
798
+ try {
799
+ fs.appendFileSync(store.pathsFor(root, env).fixes, JSON.stringify(attempt) + '\n', 'utf8');
800
+ } catch (err) {
801
+ attempt.saveError = err.message;
802
+ }
803
+ return Object.assign({ ok: true, summary: attempt.verdict }, attempt);
804
+ });
805
+ }, secretsOf(connection));
806
+ }
807
+ // Handed to the job and nowhere else. The job's lines and result are
808
+ // scrubbed of it, and it is dropped the moment the job ends.
809
+ return begin('db', DB_ACTIONS[action].title, DB_ACTIONS[action].command,
810
+ (say) => opts.db(action, connection, say), secretsOf(connection));
811
+ }
812
+ if (url.pathname === '/api/db/words' && typeof opts.words === 'function') {
813
+ // What kryptheon-night itself would say about this string - its help,
814
+ // its consent screens, its warnings - asked of it without connecting.
815
+ const connection = input.connection == null || input.connection === '' ? '' : connectionFrom(input.connection);
816
+ if (connection === null) return json(res, 400, { ok: false, why: 'that connection string is too long' });
817
+ const secrets = secretsOf(connection);
818
+ return Promise.resolve(opts.words(connection)).then(
819
+ (words) => json(res, 200, hideSecretsIn({ ok: true, words: words }, secrets)),
820
+ (err) => json(res, 500, hideSecretsIn({ ok: false, why: err.message }, secrets)),
821
+ );
822
+ }
823
+ return json(res, 404, { ok: false, why: 'no such action' });
824
+ });
825
+ return;
826
+ }
827
+ return send(res, 404, 'text/plain', 'Not found');
828
+ });
829
+
830
+ return {
831
+ server: server,
832
+ token: token,
833
+ listen(wanted) {
834
+ return new Promise((resolve, reject) => {
835
+ server.once('error', reject);
836
+ server.listen(wanted || 0, '127.0.0.1', () => {
837
+ port = server.address().port;
838
+ resolve(port);
839
+ });
840
+ });
841
+ },
842
+ close() {
843
+ return new Promise((resolve) => server.close(() => resolve()));
844
+ },
845
+ };
846
+ }
847
+
848
+ module.exports = {
849
+ CHECKS: CHECKS,
850
+ buildState: buildState,
851
+ flowPrompt: flowPrompt,
852
+ appAddress: appAddress,
853
+ secretsOf: secretsOf,
854
+ hideSecrets: hideSecrets,
855
+ hideSecretsIn: hideSecretsIn,
856
+ createJobs: createJobs,
857
+ DB_ACTIONS: DB_ACTIONS,
858
+ databaseVerdict: databaseVerdict,
859
+ startSteps: startSteps,
860
+ nextAction: nextAction,
861
+ readConfig: readConfig,
862
+ setCheck: setCheck,
863
+ createServer: createServer,
864
+ };