kryptheon 0.1.21 → 0.1.23
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 +4 -0
- package/bin/kryptheon.js +2 -0
- package/kryptheon-archivo.woff2 +0 -0
- package/kryptheon-code.js +221 -14
- package/kryptheon-dashboard-page.js +247 -553
- package/kryptheon-dashboard.html +378 -446
- package/kryptheon-dashboard.js +27 -9
- package/kryptheon-fonts-OFL.txt +98 -0
- package/kryptheon-spacemono.woff2 +0 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -174,6 +174,10 @@ replays your recordings or reads your code from the page. It is only
|
|
|
174
174
|
reachable from this machine; press Ctrl+C to stop it. From a script, use
|
|
175
175
|
`npx kryptheon dashboard`.
|
|
176
176
|
|
|
177
|
+
It opens on the one thing to do next, and under it "Start here": read your
|
|
178
|
+
code, record your main flows, replay them, check your database - in that
|
|
179
|
+
order, each marked done only once it has actually run.
|
|
180
|
+
|
|
177
181
|
Checks that are not built yet are listed as "not available" - never as
|
|
178
182
|
passed.
|
|
179
183
|
|
package/bin/kryptheon.js
CHANGED
|
@@ -2053,6 +2053,8 @@ function codeRead() {
|
|
|
2053
2053
|
console.log(' ' + f.severity + ' ' + f.file + ':' + f.line + ' (verification required)');
|
|
2054
2054
|
console.log(' ' + f.headline);
|
|
2055
2055
|
console.log(' ' + f.sink + ': ' + f.expression);
|
|
2056
|
+
const parts = (f.parts || []).filter((p) => p.text !== f.expression);
|
|
2057
|
+
if (parts.length) console.log(' the risky part' + (parts.length === 1 ? ': ' : 's: ') + parts.map((p) => p.text).join(', '));
|
|
2056
2058
|
}
|
|
2057
2059
|
const prompted = high.slice(0, CODE_PROMPTS_SHOWN);
|
|
2058
2060
|
if (prompted.length) {
|
|
Binary file
|
package/kryptheon-code.js
CHANGED
|
@@ -149,10 +149,10 @@ function classify(node, scope, code, depth, aliases) {
|
|
|
149
149
|
case 'NullLiteral':
|
|
150
150
|
return 'safe';
|
|
151
151
|
case 'TemplateLiteral':
|
|
152
|
-
return worst(node.
|
|
152
|
+
return worst(judgedParts(node, scope, code).map((e) => again(e)));
|
|
153
153
|
case 'BinaryExpression':
|
|
154
154
|
if (node.operator !== '+') return 'safe'; // - * / % comparisons: never text
|
|
155
|
-
return worst(
|
|
155
|
+
return worst(judgedParts(node, scope, code).map((e) => again(e)));
|
|
156
156
|
case 'UnaryExpression':
|
|
157
157
|
case 'UpdateExpression':
|
|
158
158
|
return 'safe';
|
|
@@ -168,16 +168,16 @@ function classify(node, scope, code, depth, aliases) {
|
|
|
168
168
|
if (NUMERIC_CALLS.test(name)) return 'safe';
|
|
169
169
|
if (node.callee.type === 'MemberExpression' && node.callee.object && node.callee.object.name === 'Math') return 'safe';
|
|
170
170
|
// items.map(i => `<li>${i.name}</li>`).join('') - judge what map builds.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const item = fn && fn.params && fn.params[0] && fn.params[0].type === 'Identifier' ? fn.params[0].name : null;
|
|
177
|
-
const inner = Object.assign({}, aliases || {});
|
|
178
|
-
if (item) inner[item] = listOrigin;
|
|
179
|
-
return classify(returnedBy(fn), scope, code, depth + 1, inner);
|
|
171
|
+
const mapped = mapCallback(node, scope, code, depth, aliases);
|
|
172
|
+
if (mapped) {
|
|
173
|
+
return mapped.returns.length
|
|
174
|
+
? worst(mapped.returns.map((r) => classify(r.node, r.scope, code, depth + 1, mapped.inner)))
|
|
175
|
+
: 'unknown';
|
|
180
176
|
}
|
|
177
|
+
// An escaping helper of this file's own, known by what it does. Inside a
|
|
178
|
+
// template, one that leaves quotes alone does not protect a value that
|
|
179
|
+
// lands in an attribute - the template judges that case itself.
|
|
180
|
+
if (escaperOf(node, scope, code)) return 'safe';
|
|
181
181
|
// A function of this file's own: as dangerous as what it returns. One
|
|
182
182
|
// that only ever returns constants - a lookup of labels, icons and
|
|
183
183
|
// colours - cannot carry anything in; one that returns a parameter
|
|
@@ -264,6 +264,154 @@ function returnsOf(fnPath) {
|
|
|
264
264
|
return found;
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
// Every function in the piece being read, by node, so a callback handed to
|
|
268
|
+
// map() can be judged in its own scope - where its own `const color = ...`
|
|
269
|
+
// lives - instead of the caller's, where that name means nothing.
|
|
270
|
+
let FN_PATHS = new WeakMap();
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* list.map(cb).join(''): the callback's returns, each with its own scope, and
|
|
274
|
+
* what the callback's names stand for - the item is whatever the list holds,
|
|
275
|
+
* the index is a number. Null when node is not that shape.
|
|
276
|
+
*/
|
|
277
|
+
function mapCallback(node, scope, code, depth, aliases) {
|
|
278
|
+
if (calleeName(node.callee) !== 'join' || !node.callee.object || !/CallExpression$/.test(node.callee.object.type) ||
|
|
279
|
+
calleeName(node.callee.object.callee) !== 'map') {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
const mapCall = node.callee.object;
|
|
283
|
+
const fn = mapCall.arguments[0];
|
|
284
|
+
const listOrigin = classify(mapCall.callee.object, scope, code, depth + 1, aliases);
|
|
285
|
+
const inner = Object.assign({}, aliases || {});
|
|
286
|
+
const params = (fn && fn.params) || [];
|
|
287
|
+
if (params[0] && params[0].type === 'Identifier') inner[params[0].name] = listOrigin;
|
|
288
|
+
if (params[1] && params[1].type === 'Identifier') inner[params[1].name] = 'safe';
|
|
289
|
+
const fnPath = fn && FN_PATHS.get(fn);
|
|
290
|
+
const returns = fnPath
|
|
291
|
+
? returnsOf(fnPath)
|
|
292
|
+
: (returnedBy(fn) ? [{ node: returnedBy(fn), scope: scope }] : []);
|
|
293
|
+
return { returns: returns, inner: inner };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Whether a call escapes HTML - by name for the well-known ones, and for a
|
|
298
|
+
* helper of this file's own, by what it does: every return is a replace()
|
|
299
|
+
* chain, its patterns cover & < and >, and it writes & < >. The
|
|
300
|
+
* name decides nothing - `esc` and `clean3` count, and an "escape" that only
|
|
301
|
+
* handles "<", or escapes and then returns the original, does not.
|
|
302
|
+
* Returns { dq, sq }: whether " and ' are escaped too. Null when it does not escape.
|
|
303
|
+
*/
|
|
304
|
+
function escaperOf(call, scope, code) {
|
|
305
|
+
if (!call || !/CallExpression$/.test(call.type)) return null;
|
|
306
|
+
const name = calleeName(call.callee);
|
|
307
|
+
if (MAKES_SAFE.test(name)) return { dq: true, sq: true };
|
|
308
|
+
if (call.callee.type !== 'Identifier') return null;
|
|
309
|
+
const fnPath = localFunction(call.callee.name, scope);
|
|
310
|
+
if (!fnPath) return null;
|
|
311
|
+
const returns = returnsOf(fnPath);
|
|
312
|
+
if (!returns.length || !returns.every((r) => /CallExpression$/.test(r.node.type) &&
|
|
313
|
+
/^(replace|replaceAll)$/.test(calleeName(r.node.callee)))) {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
let patterns = '';
|
|
317
|
+
const written = [];
|
|
318
|
+
fnPath.traverse({
|
|
319
|
+
RegExpLiteral(p) {
|
|
320
|
+
patterns += p.node.pattern;
|
|
321
|
+
},
|
|
322
|
+
StringLiteral(p) {
|
|
323
|
+
written.push(p.node.value);
|
|
324
|
+
const parent = p.parent;
|
|
325
|
+
if (parent && /CallExpression$/.test(parent.type) && parent.arguments[0] === p.node &&
|
|
326
|
+
/^(replace|replaceAll)$/.test(calleeName(parent.callee))) {
|
|
327
|
+
patterns += p.node.value;
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
const writes = (entity) => written.some((s) => s.indexOf(entity) !== -1);
|
|
332
|
+
if (!/&/.test(patterns) || !/</.test(patterns) || !/>/.test(patterns)) return null;
|
|
333
|
+
if (!writes('&') || !writes('<') || !writes('>')) return null;
|
|
334
|
+
return {
|
|
335
|
+
dq: /"/.test(patterns) && (writes('"') || writes('"') || writes('"')),
|
|
336
|
+
sq: /'/.test(patterns) && (writes(''') || writes(''') || writes(''')),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The quote an HTML attribute value is open in at the end of this text: '"',
|
|
342
|
+
* "'", 'bare' for an unquoted one, '' when the text is not inside a tag at all.
|
|
343
|
+
* Only inside a tag - after a "<" that no ">" has closed - is an "=" an
|
|
344
|
+
* attribute; the "=" of "/go?next=" in a URL string is not one.
|
|
345
|
+
*/
|
|
346
|
+
function openAttribute(text) {
|
|
347
|
+
const lt = text.lastIndexOf('<');
|
|
348
|
+
if (lt === -1 || text.lastIndexOf('>') > lt) return '';
|
|
349
|
+
const tag = text.slice(lt);
|
|
350
|
+
const quoted = /=\s*(["'])[^"']*$/.exec(tag);
|
|
351
|
+
if (quoted) return quoted[1];
|
|
352
|
+
return /\s[^\s"'<>\/=]+\s*=\s*$/.test(tag) ? 'bare' : '';
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The pieces of a template or a + concatenation to judge one by one. An
|
|
357
|
+
* escaped value is judged as its escaped input when the escaping is not
|
|
358
|
+
* enough for where it lands: inside a "..." attribute that needs " escaped,
|
|
359
|
+
* inside '...' that needs ' escaped, and an unquoted attribute is broken by a
|
|
360
|
+
* space no escaper touches.
|
|
361
|
+
*/
|
|
362
|
+
function judgedParts(node, scope, code) {
|
|
363
|
+
const pairs = [];
|
|
364
|
+
if (node.type === 'TemplateLiteral') {
|
|
365
|
+
// Everything written before this value, so an attribute opened in an
|
|
366
|
+
// earlier piece - `<a title="${a}" href="${b}"` - is still seen as open.
|
|
367
|
+
let before = '';
|
|
368
|
+
node.expressions.forEach((e, i) => {
|
|
369
|
+
before += node.quasis[i].value.raw;
|
|
370
|
+
pairs.push({ before: before, value: e });
|
|
371
|
+
before += 'x';
|
|
372
|
+
});
|
|
373
|
+
} else {
|
|
374
|
+
const text = (n) => (n.type === 'StringLiteral' ? n.value
|
|
375
|
+
: n.type === 'TemplateLiteral' ? n.quasis[n.quasis.length - 1].value.raw : '');
|
|
376
|
+
pairs.push({ before: '', value: node.left });
|
|
377
|
+
pairs.push({ before: text(node.left), value: node.right });
|
|
378
|
+
}
|
|
379
|
+
return pairs.map(({ before, value }) => {
|
|
380
|
+
const esc = escaperOf(value, scope, code);
|
|
381
|
+
if (!esc) return value;
|
|
382
|
+
const quote = openAttribute(before);
|
|
383
|
+
const enough = quote === '' || (quote === '"' && esc.dq) || (quote === "'" && esc.sq);
|
|
384
|
+
return enough ? { type: 'StringLiteral' } : (value.arguments[0] || value);
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Which parts of a value make it unsafe, in the words of the code - so a fix
|
|
390
|
+
* prompt can say "only ${log.caller_number}" instead of quoting sixty lines of
|
|
391
|
+
* template and leaving the reader to find it. At most five, never repeated.
|
|
392
|
+
*/
|
|
393
|
+
function riskyParts(node, scope, code, aliases, out, depth) {
|
|
394
|
+
if (!node || out.length >= 5 || depth > MAX_DEPTH) return;
|
|
395
|
+
const origin = classify(node, scope, code, 0, aliases);
|
|
396
|
+
if (origin === 'safe') return;
|
|
397
|
+
const go = (n, s, a) => riskyParts(n, s || scope, code, a || aliases, out, depth + 1);
|
|
398
|
+
if (node.type === 'TemplateLiteral' || (node.type === 'BinaryExpression' && node.operator === '+')) {
|
|
399
|
+
judgedParts(node, scope, code).forEach((p) => go(p));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (node.type === 'ConditionalExpression') { go(node.consequent); go(node.alternate); return; }
|
|
403
|
+
if (node.type === 'LogicalExpression') { go(node.left); go(node.right); return; }
|
|
404
|
+
if (/CallExpression$/.test(node.type)) {
|
|
405
|
+
const mapped = mapCallback(node, scope, code, depth, aliases);
|
|
406
|
+
if (mapped && mapped.returns.length) {
|
|
407
|
+
mapped.returns.forEach((r) => go(r.node, r.scope, mapped.inner));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const text = code.slice(node.start, node.end).replace(/\s+/g, ' ').slice(0, 80);
|
|
412
|
+
if (!out.some((p) => p.text === text)) out.push({ text: text, origin: origin });
|
|
413
|
+
}
|
|
414
|
+
|
|
267
415
|
function rootIdentifier(node) {
|
|
268
416
|
let n = node;
|
|
269
417
|
while (n && /MemberExpression$/.test(n.type)) n = n.object;
|
|
@@ -284,13 +432,50 @@ function fromBinding(id, scope, code, depth, aliases) {
|
|
|
284
432
|
const binding = scope.getBinding(id.name);
|
|
285
433
|
if (!binding) return 'unknown';
|
|
286
434
|
if (binding.path && binding.path.type === 'CatchClause') return 'error';
|
|
435
|
+
if (binding.kind === 'param') return fromCalls(binding, code, depth);
|
|
287
436
|
const init = binding.path && binding.path.node && binding.path.node.init;
|
|
288
437
|
if (!init) return 'unknown';
|
|
289
438
|
if (readsResponseBody(init)) return 'network';
|
|
290
439
|
// Reassigned later: the first value says nothing reliable about the last.
|
|
291
440
|
if (binding.constantViolations && binding.constantViolations.length) return 'unknown';
|
|
292
441
|
// Destructured from a response body: const { name } = await res.json()
|
|
293
|
-
|
|
442
|
+
// Aliases carry through: inside a map() callback, `const t = r.title` is as
|
|
443
|
+
// dangerous as the item r it was read from.
|
|
444
|
+
return classify(init, binding.path.scope, code, depth + 1, aliases);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* A parameter is as dangerous as the worst thing any call passes it. Every
|
|
449
|
+
* use of the function has to be a call this file makes: one handed on as a
|
|
450
|
+
* value - a callback, an export, an event handler - is called from somewhere
|
|
451
|
+
* the code does not show, so its parameter stays unknown.
|
|
452
|
+
*/
|
|
453
|
+
function fromCalls(binding, code, depth) {
|
|
454
|
+
const fn = binding.path && binding.path.parentPath;
|
|
455
|
+
if (!fn || !/Function/.test(fn.node.type)) return 'unknown';
|
|
456
|
+
const index = fn.node.params.indexOf(binding.path.node);
|
|
457
|
+
if (index === -1) return 'unknown';
|
|
458
|
+
let name = fn.node.id && fn.node.id.name;
|
|
459
|
+
if (!name && fn.parentPath && fn.parentPath.node.type === 'VariableDeclarator' && fn.parentPath.node.id.type === 'Identifier') {
|
|
460
|
+
name = fn.parentPath.node.id.name;
|
|
461
|
+
}
|
|
462
|
+
const fnBinding = name && fn.parentPath && fn.parentPath.scope.getBinding(name);
|
|
463
|
+
if (!fnBinding || (fnBinding.constantViolations && fnBinding.constantViolations.length)) return 'unknown';
|
|
464
|
+
const refs = fnBinding.referencePaths || [];
|
|
465
|
+
if (!refs.length) return 'unknown';
|
|
466
|
+
const origins = [];
|
|
467
|
+
for (const ref of refs) {
|
|
468
|
+
const call = ref.parent;
|
|
469
|
+
if (!call || !/CallExpression$/.test(call.type) || call.callee !== ref.node) return 'unknown';
|
|
470
|
+
const arg = call.arguments[index];
|
|
471
|
+
if (!arg) {
|
|
472
|
+
origins.push('safe'); // not passed: undefined
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (call.arguments.slice(0, index + 1).some((a) => a.type === 'SpreadElement')) return 'unknown';
|
|
476
|
+
origins.push(classify(arg, ref.scope, code, depth + 1));
|
|
477
|
+
}
|
|
478
|
+
return worst(origins);
|
|
294
479
|
}
|
|
295
480
|
|
|
296
481
|
function readsResponseBody(node) {
|
|
@@ -342,6 +527,11 @@ function sinksIn(parser, code, file, lineOffset) {
|
|
|
342
527
|
// each call actually hands it.
|
|
343
528
|
const calls = new Map();
|
|
344
529
|
const viaHelper = [];
|
|
530
|
+
const partsOf = (value, scope) => {
|
|
531
|
+
const out = [];
|
|
532
|
+
riskyParts(value, scope, code, undefined, out, 0);
|
|
533
|
+
return out;
|
|
534
|
+
};
|
|
345
535
|
const add = (node, scope, sink, value) => {
|
|
346
536
|
const param = paramOf(value, scope);
|
|
347
537
|
if (param) {
|
|
@@ -350,9 +540,13 @@ function sinksIn(parser, code, file, lineOffset) {
|
|
|
350
540
|
}
|
|
351
541
|
const origin = classify(value, scope, code, 0);
|
|
352
542
|
if (origin === 'safe') return;
|
|
353
|
-
found.push({ line: lineOf(node), sink: sink, expression: textOf(value), origin: origin });
|
|
543
|
+
found.push({ line: lineOf(node), sink: sink, expression: textOf(value), origin: origin, parts: partsOf(value, scope) });
|
|
354
544
|
};
|
|
545
|
+
FN_PATHS = new WeakMap();
|
|
355
546
|
parser.traverse(ast, {
|
|
547
|
+
Function(p) {
|
|
548
|
+
FN_PATHS.set(p.node, p);
|
|
549
|
+
},
|
|
356
550
|
CallExpression: {
|
|
357
551
|
exit(p) {
|
|
358
552
|
if (p.node.callee.type !== 'Identifier') return;
|
|
@@ -411,6 +605,7 @@ function sinksIn(parser, code, file, lineOffset) {
|
|
|
411
605
|
sink: h.sink,
|
|
412
606
|
expression: textOf(arg),
|
|
413
607
|
origin: origin,
|
|
608
|
+
parts: partsOf(arg, call.scope),
|
|
414
609
|
via: { fn: h.param.fn, line: lineOf(h.node) },
|
|
415
610
|
});
|
|
416
611
|
}
|
|
@@ -518,18 +713,29 @@ function describe(f) {
|
|
|
518
713
|
'HTML entities). If the HTML is meant to allow some formatting, pass it through a sanitizer ' +
|
|
519
714
|
'such as DOMPurify first.',
|
|
520
715
|
];
|
|
716
|
+
// A long template is mostly harmless markup. Naming the part that decides it
|
|
717
|
+
// keeps the fix - and the AI tool doing it - to that part alone.
|
|
718
|
+
const parts = (f.parts || []).filter((p) => p.text && p.text !== f.expression);
|
|
719
|
+
const only = parts.length
|
|
720
|
+
? [''].concat(
|
|
721
|
+
[(parts.length === 1 ? 'Only this part' : 'Only these parts') + ' of it can carry text from outside the page, ' +
|
|
722
|
+
'so only ' + (parts.length === 1 ? 'it needs' : 'they need') + ' changing:'],
|
|
723
|
+
parts.map((p) => ' ${' + p.text + '}'),
|
|
724
|
+
)
|
|
725
|
+
: [];
|
|
521
726
|
const fixPrompt = [
|
|
522
727
|
'My app may have a security problem - please check it rather than assume it.',
|
|
523
728
|
'',
|
|
524
729
|
where,
|
|
525
730
|
'',
|
|
526
731
|
' ' + f.expression,
|
|
732
|
+
].concat(only, [
|
|
527
733
|
'',
|
|
528
734
|
'The value is ' + ORIGIN_WORDS[f.origin] + '. If it can ever contain text a user or another ' +
|
|
529
735
|
'account typed, a "<" in it becomes a real tag and can run script in this page with the ' +
|
|
530
736
|
"signed-in person's session.",
|
|
531
737
|
'',
|
|
532
|
-
].concat(fix, [
|
|
738
|
+
]).concat(fix, [
|
|
533
739
|
'',
|
|
534
740
|
'Keep the page looking the same, change only how this value is inserted, and look for the ' +
|
|
535
741
|
'same pattern elsewhere in this file. Afterwards, open this page and check that ordinary ' +
|
|
@@ -546,6 +752,7 @@ function describe(f) {
|
|
|
546
752
|
sink: f.sink,
|
|
547
753
|
expression: f.expression,
|
|
548
754
|
origin: f.origin,
|
|
755
|
+
parts: (f.parts || []).map((p) => ({ text: p.text, origin: p.origin })),
|
|
549
756
|
via: f.via || null,
|
|
550
757
|
headline: 'Text ' + (f.origin === 'unknown' ? 'of unknown origin' : ORIGIN_WORDS[f.origin].split(',')[0]) +
|
|
551
758
|
' is inserted as HTML in ' + f.file + ':' + f.line +
|