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 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.expressions.map((e) => again(e)));
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([again(node.left), again(node.right)]);
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
- if (name === 'join' && node.callee.object && /CallExpression$/.test(node.callee.object.type) &&
172
- calleeName(node.callee.object.callee) === 'map') {
173
- const fn = node.callee.object.arguments[0];
174
- // The callback's item is whatever the array it walks holds.
175
- const listOrigin = again(node.callee.object.callee.object);
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 &amp; &lt; &gt;. 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('&amp;') || !writes('&lt;') || !writes('&gt;')) return null;
334
+ return {
335
+ dq: /"/.test(patterns) && (writes('&quot;') || writes('&#34;') || writes('&#x22;')),
336
+ sq: /'/.test(patterns) && (writes('&#39;') || writes('&#x27;') || writes('&apos;')),
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
- return classify(init, binding.path.scope, code, depth + 1);
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 +