kryptheon 0.1.25 → 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.
- package/README.md +33 -7
- package/bin/kryptheon.js +199 -43
- package/kryptheon-dashboard-page.js +1026 -697
- package/kryptheon-dashboard.html +411 -378
- package/kryptheon-dashboard.js +864 -550
- package/kryptheon-store.js +2 -0
- package/package.json +1 -1
package/kryptheon-dashboard.js
CHANGED
|
@@ -1,550 +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 (
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
findings
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
'
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
if (
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
if (
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
}
|
|
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
|
+
};
|