kryptheon 0.1.20 → 0.1.22

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.
@@ -0,0 +1,539 @@
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
+ /** When this flow last passed, from the run history, or null. */
131
+ function lastPassOf(history, title) {
132
+ for (let i = history.length - 1; i >= 0; i--) {
133
+ const t = (history[i].tests || []).find((x) => x.title === title);
134
+ if (t && t.status === 'passed') return history[i].runAt;
135
+ }
136
+ return null;
137
+ }
138
+
139
+ /**
140
+ * The fix prompt for a broken recorded flow, from what the run kept: what was
141
+ * seen, what was expected, where, the evidence, and how to see it again. Only
142
+ * what the run recorded is said; nothing is guessed.
143
+ */
144
+ function flowPrompt(t, lastPass) {
145
+ const f = t.failure || {};
146
+ const o = f.observations || {};
147
+ const file = String(f.file || t.specFile || 'tests').split('\\').join('/');
148
+ const extra = String(f.rawMessage || '').split(/\r?\n/).slice(1)
149
+ .map((l) => l.trim()).filter((l) => l && !/^\s*at\s/.test(l) && l.length < 200).slice(0, 6);
150
+ const lines = [
151
+ 'My app has a broken flow. Please fix the app - and nothing else.',
152
+ '',
153
+ 'Kryptheon replayed the recorded flow "' + t.title + '" (' + file + (f.line ? ', line ' + f.line : '') + ') and it failed.',
154
+ '',
155
+ 'Observed: ' + (f.plainLanguage || 'the replay failed'),
156
+ ].concat(extra.map((l) => ' ' + l), [
157
+ 'Expected: the same result as ' + (lastPass ? 'the last time it passed (' + lastPass + ')' : 'when it was recorded') + '.',
158
+ ]);
159
+ if (o.url) lines.push('Where: the browser was on ' + o.url + (o.pageShowed ? ', and the page showed ' + o.pageShowed : '') + '.');
160
+ if (f.locator) lines.push('Step that failed: ' + f.locator);
161
+ 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('; '));
162
+ if (o.consoleErrors && o.consoleErrors.length) lines.push('Console errors: ' + o.consoleErrors.slice(0, 3).join(' | '));
163
+ if (f.screenshot) lines.push('Evidence: a screenshot of the failure at ' + f.screenshot);
164
+ lines.push('');
165
+ lines.push('To see it yourself: open ' + (o.url || 'the app') + ' and repeat the steps in ' + file + ', or run npx kryptheon check');
166
+ lines.push('');
167
+ lines.push('Fix the app so this flow works again. Do not edit the recording to make it pass. If the new behaviour ' +
168
+ 'is intended, say so instead of changing code - it can be accepted with npx kryptheon accept "' + t.title + '"');
169
+ return lines.join('\n');
170
+ }
171
+
172
+ function countRecordings(root) {
173
+ try {
174
+ return fs.readdirSync(path.join(root, 'tests')).filter((n) => /\.(spec|test)\.[cm]?[jt]s$/.test(n)).length;
175
+ } catch (err) {
176
+ return 0;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * The order someone new should go in, one step at a time. A step is "done"
182
+ * only when it has actually run and left a record - never because nothing was
183
+ * found - and a switched-off check says "off", so an empty list is never read
184
+ * as a clean one.
185
+ */
186
+ function startSteps(checks, seen) {
187
+ const on = (id) => (checks.find((c) => c.id === id) || {}).enabled;
188
+ return [
189
+ {
190
+ id: 'frontend',
191
+ title: 'Read your frontend code',
192
+ why: 'Finds places where text could be put into your page as HTML. Needs nothing from you.',
193
+ state: !on('frontend') ? 'off' : seen.codeAt ? 'done' : 'todo',
194
+ at: seen.codeAt,
195
+ action: on('frontend') ? { kind: 'run', check: 'frontend', label: seen.codeAt ? 'Read it again' : 'Read my code' } : null,
196
+ },
197
+ {
198
+ id: 'record',
199
+ title: 'Record your main flows',
200
+ why: 'Sign up, log in, pay - do each once while Kryptheon watches. It then knows what "working" looks like.',
201
+ state: seen.recordings ? 'done' : 'todo',
202
+ at: null,
203
+ count: seen.recordings,
204
+ action: { kind: 'command', command: 'npx kryptheon record <your app address>' },
205
+ },
206
+ {
207
+ id: 'regression',
208
+ title: 'Replay them',
209
+ why: 'Runs every recording against your app and compares each page with the last time it worked. Start your app first.',
210
+ state: !on('regression') ? 'off' : !seen.recordings ? 'waiting' : seen.lastRunAt ? 'done' : 'todo',
211
+ at: seen.lastRunAt,
212
+ action: on('regression') && seen.recordings ? { kind: 'run', check: 'regression', label: seen.lastRunAt ? 'Replay again' : 'Replay now' } : null,
213
+ },
214
+ {
215
+ id: 'data',
216
+ title: 'Check your database',
217
+ why: 'Attacks a copy of your Postgres or Supabase database and reports what got in. Needs your connection string, so it runs in your terminal.',
218
+ state: !on('data') ? 'off' : seen.nightAt ? 'done' : 'todo',
219
+ at: seen.nightAt,
220
+ action: on('data') ? { kind: 'command', command: 'npx kryptheon-night' } : null,
221
+ },
222
+ ];
223
+ }
224
+
225
+ /**
226
+ * The one thing to do now. Open confirmed problems come before everything; then
227
+ * the first step not taken; then what only needs a look; then nothing.
228
+ */
229
+ function nextAction(findings, steps) {
230
+ const confirmed = findings.filter((f) => f.status === 'confirmed').length;
231
+ const toVerify = findings.length - confirmed;
232
+ if (confirmed) {
233
+ return { kind: 'fix', view: 'findings', count: confirmed,
234
+ title: confirmed === 1 ? 'Fix 1 confirmed problem' : 'Fix ' + confirmed + ' confirmed problems' };
235
+ }
236
+ const step = steps.find((s) => s.state === 'todo');
237
+ if (step) return { kind: 'step', step: step.id, title: step.title };
238
+ if (toVerify) {
239
+ return { kind: 'verify', view: 'findings', count: toVerify,
240
+ title: toVerify === 1 ? 'Look at 1 thing to verify' : 'Look at ' + toVerify + ' things to verify' };
241
+ }
242
+ return { kind: 'quiet', title: 'Nothing open' };
243
+ }
244
+
245
+ /**
246
+ * Everything the page shows, from the store alone. Findings from every check
247
+ * are put into one list, each carrying the same fields - severity, status,
248
+ * evidence, confidence, where, what, and the fix - so the page can show them
249
+ * the same way whichever check found them.
250
+ */
251
+ function buildState(root, env) {
252
+ const paths = store.pathsFor(root, env);
253
+ const config = readConfig(paths);
254
+ const history = readHistory(paths.history, 20);
255
+ const baselines = readJson(paths.baselines) || {};
256
+ const code = readJson(paths.codeFindings);
257
+ const night = readJson(paths.nightLast);
258
+
259
+ const findings = [];
260
+
261
+ // Recorded flows: the last run's broken tests are the findings. They were
262
+ // replayed in a real browser, so they are confirmed at runtime.
263
+ const lastRun = history.length ? history[history.length - 1] : null;
264
+ if (lastRun && config.enabled.regression) {
265
+ for (const t of lastRun.tests || []) {
266
+ if (t.status !== 'failed' && t.status !== 'timedOut') continue;
267
+ findings.push({
268
+ check: 'regression',
269
+ severity: 'HIGH',
270
+ status: 'confirmed',
271
+ evidence: 'runtime confirmed',
272
+ confidence: 'high',
273
+ where: String((t.failure && t.failure.file) || t.specFile || 'tests').split('\\').join('/') +
274
+ (t.failure && t.failure.line ? ':' + t.failure.line : ''),
275
+ headline: 'The recorded flow "' + t.title + '" broke.',
276
+ detail: (t.failure && t.failure.plainLanguage) || '',
277
+ fixPrompt: flowPrompt(t, lastPassOf(history, t.title)),
278
+ when: lastRun.runAt,
279
+ });
280
+ }
281
+ }
282
+
283
+ if (code && config.enabled.frontend) {
284
+ for (const f of code.findings || []) {
285
+ findings.push({
286
+ check: 'frontend',
287
+ severity: f.severity,
288
+ status: f.status,
289
+ evidence: f.evidence,
290
+ confidence: f.confidence,
291
+ where: f.file + ':' + f.line,
292
+ headline: f.headline,
293
+ detail: f.sink + ': ' + f.expression,
294
+ fixPrompt: f.fixPrompt,
295
+ when: code.checkedAt,
296
+ });
297
+ }
298
+ }
299
+
300
+ let nightWhen = null;
301
+ try {
302
+ nightWhen = fs.statSync(paths.nightLast).mtime.toISOString();
303
+ } catch (err) {
304
+ /* never run */
305
+ }
306
+ if (night && config.enabled.data) {
307
+ for (const f of night.findings || []) {
308
+ findings.push({
309
+ check: 'data',
310
+ severity: f.severity,
311
+ status: f.status || 'confirmed',
312
+ evidence: (f.status || 'confirmed') === 'confirmed' ? 'runtime confirmed' : 'code analysis',
313
+ confidence: (f.status || 'confirmed') === 'confirmed' ? 'high' : 'medium',
314
+ where: f.table + (f.column ? '.' + f.column : ''),
315
+ headline: f.headline,
316
+ detail: f.body || '',
317
+ fixPrompt: f.fixPrompt || '',
318
+ when: nightWhen,
319
+ });
320
+ }
321
+ }
322
+
323
+ const rank = { 'HIGH CRITICAL': 0, CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
324
+ findings.sort((a, b) => (rank[a.severity] ?? 9) - (rank[b.severity] ?? 9) ||
325
+ (a.status === 'confirmed' ? 0 : 1) - (b.status === 'confirmed' ? 0 : 1));
326
+
327
+ // Every finding gets a short id and the last word of its fix prompt: how to
328
+ // prove the fix. Added here, where the loop lives, so every check's prompt
329
+ // ends the same way and the detection code itself stays untouched.
330
+ const attempts = readHistory(paths.fixes, 500);
331
+ for (const f of findings) {
332
+ f.id = changes.findingId(f);
333
+ f.attempts = attempts.filter((a) => a.id === f.id).map((a) => ({ at: a.at, verdict: a.verdict, why: a.why || '', newProblems: (a.newProblems || []).length }));
334
+ if (f.fixPrompt) {
335
+ f.fixPrompt += '\n\nWhen you have fixed it, prove it: run npx kryptheon recheck ' + f.id +
336
+ ' (or press Re-check on this finding in the Kryptheon dashboard). It is fixed only when that says FIXED.';
337
+ }
338
+ }
339
+ // Fixes that were proven, newest first, while their finding is still gone.
340
+ const open = new Set(findings.map((f) => f.id));
341
+ const proven = attempts.filter((a) => a.verdict === 'fixed' && !open.has(a.id)).reverse().slice(0, 10)
342
+ .map((a) => ({ id: a.id, at: a.at, where: a.where, headline: a.headline, newProblems: (a.newProblems || []).length }));
343
+
344
+ const recordings = countRecordings(root);
345
+ const checks = CHECKS.map((c) => {
346
+ let state;
347
+ if (!c.available) state = 'not available';
348
+ else if (!config.enabled[c.id]) state = 'switched off';
349
+ else if (c.id === 'regression') state = !recordings ? 'no recordings yet' : !lastRun ? 'never run' : lastRun.failed ? 'problems found' : 'last run held';
350
+ else if (c.id === 'frontend') state = !code ? 'never run' : (code.findings || []).length ? 'things to check' : 'nothing found';
351
+ else if (c.id === 'data') state = !night ? 'never run here' : (night.findings || []).length ? 'problems found' : 'last run held';
352
+ return {
353
+ id: c.id, label: c.label, available: c.available, enabled: config.enabled[c.id], state: state, what: c.what || '',
354
+ // Runnable from the page: what needs nothing the page cannot supply. The
355
+ // database check needs a connection string, which stays in the terminal.
356
+ runnable: c.available && (c.id === 'frontend' || (c.id === 'regression' && recordings > 0)),
357
+ };
358
+ });
359
+
360
+ const steps = startSteps(checks, {
361
+ codeAt: code ? code.checkedAt : null,
362
+ recordings: recordings,
363
+ lastRunAt: lastRun ? lastRun.runAt : null,
364
+ nightAt: night ? nightWhen : null,
365
+ });
366
+
367
+ // Every look Kryptheon took - by `verify`, or by the dashboard's own watch -
368
+ // newest first: what changed, what was run, and what came of it.
369
+ const looks = readHistory(paths.changes, 15).reverse().map((e) => ({
370
+ at: e.at,
371
+ by: e.by || 'verify',
372
+ first: !!e.first,
373
+ files: (e.files || []).slice(0, 30),
374
+ fileCount: (e.files || []).length,
375
+ parts: e.parts || [],
376
+ dependencies: e.dependencies || [],
377
+ checks: e.checks || [],
378
+ newFindings: e.newFindings || 0,
379
+ fixed: e.fixed || 0,
380
+ goneWithFile: e.goneWithFile || 0,
381
+ }));
382
+
383
+ return {
384
+ project: { name: path.basename(root), root: root, storeDir: paths.dir },
385
+ looks: looks,
386
+ proven: proven,
387
+ checks: checks,
388
+ findings: findings,
389
+ runs: history.slice().reverse().map((r) => ({ runAt: r.runAt, status: r.status, passed: r.passed, failed: r.failed, durationMs: r.durationMs })),
390
+ recordings: recordings,
391
+ steps: steps,
392
+ next: nextAction(findings, steps),
393
+ baselines: Object.keys(baselines).length,
394
+ changes: recentChanges(root),
395
+ // Part 3 is where a version gets verified. Until then this says so,
396
+ // rather than leaving a blank that could be read either way.
397
+ verification: { state: 'not verified', why: 'Kryptheon Verified is not built yet. No version of this project has been verified.' },
398
+ };
399
+ }
400
+
401
+ /**
402
+ * The server. Local only, and closed to other web pages:
403
+ * - it listens on 127.0.0.1, never on the network;
404
+ * - it answers only when the Host header is this address, so a page on some
405
+ * other site cannot reach it through DNS rebinding;
406
+ * - anything that changes something needs the token that is written into the
407
+ * page this server hands out, which another site cannot read.
408
+ */
409
+ function createServer(root, options) {
410
+ const opts = options || {};
411
+ const env = opts.env || process.env;
412
+ const token = crypto.randomBytes(24).toString('hex');
413
+ const page = fs.readFileSync(path.join(__dirname, 'kryptheon-dashboard.html'), 'utf8');
414
+ let port = 0;
415
+ let running = false;
416
+
417
+ const allowedHosts = () => new Set(['127.0.0.1:' + port, 'localhost:' + port]);
418
+ const send = (res, code, type, body) => {
419
+ res.writeHead(code, {
420
+ 'content-type': type,
421
+ 'cache-control': 'no-store',
422
+ 'x-content-type-options': 'nosniff',
423
+ 'referrer-policy': 'no-referrer',
424
+ // The page loads nothing from anywhere else, and says so.
425
+ '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'",
426
+ });
427
+ res.end(body);
428
+ };
429
+ // Files served as they are from next to this one. A missing one is a
430
+ // missing picture or font, never a stopped dashboard.
431
+ const ASSETS = {
432
+ '/logo.png': ['image/png', opts.logoFile || path.join(__dirname, 'kryptheon-logo.png')],
433
+ '/fonts/archivo.woff2': ['font/woff2', path.join(__dirname, 'kryptheon-archivo.woff2')],
434
+ '/fonts/spacemono.woff2': ['font/woff2', path.join(__dirname, 'kryptheon-spacemono.woff2')],
435
+ };
436
+ const json = (res, code, value) => send(res, code, 'application/json; charset=utf-8', JSON.stringify(value));
437
+
438
+ const server = http.createServer((req, res) => {
439
+ if (!allowedHosts().has(String(req.headers.host || ''))) return send(res, 403, 'text/plain', 'Forbidden');
440
+ const url = new URL(req.url, 'http://127.0.0.1');
441
+
442
+ if (req.method === 'GET' && url.pathname === '/') {
443
+ return send(res, 200, 'text/html; charset=utf-8', page.replace('%%TOKEN%%', token));
444
+ }
445
+ if (req.method === 'GET' && url.pathname === '/dashboard.js') {
446
+ return send(res, 200, 'text/javascript; charset=utf-8', fs.readFileSync(path.join(__dirname, 'kryptheon-dashboard-page.js'), 'utf8'));
447
+ }
448
+ if (req.method === 'GET' && Object.prototype.hasOwnProperty.call(ASSETS, url.pathname)) {
449
+ const [type, file] = ASSETS[url.pathname];
450
+ let body;
451
+ try {
452
+ body = fs.readFileSync(file);
453
+ } catch (err) {
454
+ return send(res, 404, 'text/plain', 'Not found');
455
+ }
456
+ return send(res, 200, type, body);
457
+ }
458
+ if (req.method === 'GET' && url.pathname === '/api/state') {
459
+ return json(res, 200, buildState(root, env));
460
+ }
461
+ if (req.method === 'POST') {
462
+ if (req.headers['x-kryptheon-token'] !== token) return json(res, 403, { ok: false, why: 'missing or wrong token' });
463
+ let body = '';
464
+ req.on('data', (chunk) => {
465
+ body += chunk;
466
+ if (body.length > 10000) req.destroy();
467
+ });
468
+ req.on('end', () => {
469
+ let input;
470
+ try {
471
+ input = JSON.parse(body || '{}');
472
+ } catch (err) {
473
+ return json(res, 400, { ok: false, why: 'not JSON' });
474
+ }
475
+ if (url.pathname === '/api/check') {
476
+ const r = setCheck(store.pathsFor(root, env), String(input.id || ''), input.on);
477
+ return json(res, r.ok ? 200 : 400, r);
478
+ }
479
+ if (url.pathname === '/api/recheck' && typeof opts.recheck === 'function') {
480
+ const id = String(input.id || '');
481
+ if (!/^[0-9a-f]{8}$/.test(id)) return json(res, 400, { ok: false, why: 'not a finding id' });
482
+ if (!buildState(root, env).findings.some((f) => f.id === id)) return json(res, 404, { ok: false, why: 'no open finding with that id' });
483
+ if (running) return json(res, 409, { ok: false, why: 'a run is already going' });
484
+ running = true;
485
+ return Promise.resolve(opts.recheck(id)).finally(() => { running = false; }).then(
486
+ (r) => json(res, r && r.verdict ? 200 : 500, Object.assign({ ok: !!(r && r.verdict) }, r || {})),
487
+ (err) => json(res, 500, { ok: false, why: err.message }),
488
+ );
489
+ }
490
+ if (url.pathname === '/api/run' && typeof opts.run === 'function') {
491
+ const id = String(input.id || '');
492
+ const check = buildState(root, env).checks.find((c) => c.id === id);
493
+ if (!check || !check.runnable || !check.enabled) {
494
+ return json(res, 400, { ok: false, why: (check ? check.label : '"' + id + '"') + ' cannot be run from here' });
495
+ }
496
+ // One run at a time: two browsers replaying the same flows against
497
+ // one app would interfere, and the results would describe neither.
498
+ if (running) return json(res, 409, { ok: false, why: 'a run is already going' });
499
+ running = true;
500
+ return Promise.resolve(opts.run(id)).finally(() => { running = false; }).then(
501
+ (r) => json(res, r && r.ok ? 200 : 400, r || { ok: false }),
502
+ (err) => json(res, 500, { ok: false, why: err.message }),
503
+ );
504
+ }
505
+ return json(res, 404, { ok: false, why: 'no such action' });
506
+ });
507
+ return;
508
+ }
509
+ return send(res, 404, 'text/plain', 'Not found');
510
+ });
511
+
512
+ return {
513
+ server: server,
514
+ token: token,
515
+ listen(wanted) {
516
+ return new Promise((resolve, reject) => {
517
+ server.once('error', reject);
518
+ server.listen(wanted || 0, '127.0.0.1', () => {
519
+ port = server.address().port;
520
+ resolve(port);
521
+ });
522
+ });
523
+ },
524
+ close() {
525
+ return new Promise((resolve) => server.close(() => resolve()));
526
+ },
527
+ };
528
+ }
529
+
530
+ module.exports = {
531
+ CHECKS: CHECKS,
532
+ buildState: buildState,
533
+ flowPrompt: flowPrompt,
534
+ startSteps: startSteps,
535
+ nextAction: nextAction,
536
+ readConfig: readConfig,
537
+ setCheck: setCheck,
538
+ createServer: createServer,
539
+ };
@@ -0,0 +1,98 @@
1
+ The dashboard ships two fonts, both under the SIL Open Font License 1.1:
2
+
3
+ kryptheon-archivo.woff2 Archivo (latin subset, variable weight and width)
4
+ Copyright 2020 The Archivo Project Authors
5
+ (https://github.com/Omnibus-Type/Archivo)
6
+
7
+ kryptheon-spacemono.woff2 Space Mono (latin subset, regular)
8
+ Copyright 2016 Google Inc. All Rights Reserved.
9
+
10
+ They are the same files kryptheon.tech uses, so the dashboard and the site
11
+ read alike. The license follows.
12
+
13
+ -----------------------------------------------------------
14
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
15
+ -----------------------------------------------------------
16
+
17
+ PREAMBLE
18
+ The goals of the Open Font License (OFL) are to stimulate worldwide
19
+ development of collaborative font projects, to support the font creation
20
+ efforts of academic and linguistic communities, and to provide a free and
21
+ open framework in which fonts may be shared and improved in partnership
22
+ with others.
23
+
24
+ The OFL allows the licensed fonts to be used, studied, modified and
25
+ redistributed freely as long as they are not sold by themselves. The
26
+ fonts, including any derivative works, can be bundled, embedded,
27
+ redistributed and/or sold with any software provided that any reserved
28
+ names are not used by derivative works. The fonts and derivatives,
29
+ however, cannot be released under any other type of license. The
30
+ requirement for fonts to remain under this license does not apply
31
+ to any document created using the fonts or their derivatives.
32
+
33
+ DEFINITIONS
34
+ "Font Software" refers to the set of files released by the Copyright
35
+ Holder(s) under this license and clearly marked as such. This may
36
+ include source files, build scripts and documentation.
37
+
38
+ "Reserved Font Name" refers to any names specified as such after the
39
+ copyright statement(s).
40
+
41
+ "Original Version" refers to the collection of Font Software components as
42
+ distributed by the Copyright Holder(s).
43
+
44
+ "Modified Version" refers to any derivative made by adding to, deleting,
45
+ or substituting -- in part or in whole -- any of the components of the
46
+ Original Version, by changing formats or by porting the Font Software to a
47
+ new environment.
48
+
49
+ "Author" refers to any designer, engineer, programmer, technical
50
+ writer or other person who contributed to the Font Software.
51
+
52
+ PERMISSION & CONDITIONS
53
+ Permission is hereby granted, free of charge, to any person obtaining
54
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
55
+ redistribute, and sell modified and unmodified copies of the Font
56
+ Software, subject to the following conditions:
57
+
58
+ 1) Neither the Font Software nor any of its individual components,
59
+ in Original or Modified Versions, may be sold by itself.
60
+
61
+ 2) Original or Modified Versions of the Font Software may be bundled,
62
+ redistributed and/or sold with any software, provided that each copy
63
+ contains the above copyright notice and this license. These can be
64
+ included either as stand-alone text files, human-readable headers or
65
+ in the appropriate machine-readable metadata fields within text or
66
+ binary files as long as those fields can be easily viewed by the user.
67
+
68
+ 3) No Modified Version of the Font Software may use the Reserved Font
69
+ Name(s) unless explicit written permission is granted by the corresponding
70
+ Copyright Holder. This restriction only applies to the primary font name as
71
+ presented to the users.
72
+
73
+ 4) The name(s) of the Copyright Holder(s) and the Author(s) of the Font
74
+ Software shall not be used to promote, endorse or advertise any
75
+ Modified Version, except to acknowledge the contribution(s) of the
76
+ Copyright Holder(s) and the Author(s) or with their explicit written
77
+ permission.
78
+
79
+ 5) The Font Software, modified or unmodified, in part or in whole,
80
+ must be distributed entirely under this license, and must not be
81
+ distributed under any other license. The requirement for fonts to
82
+ remain under this license does not apply to any document created
83
+ using the Font Software.
84
+
85
+ TERMINATION
86
+ This license becomes null and void if any of the above conditions are
87
+ not met.
88
+
89
+ DISCLAIMER
90
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
91
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
92
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
93
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
94
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
95
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
96
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
97
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
98
+ OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file