kryptheon 0.1.24 → 0.1.26

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/bin/kryptheon.js CHANGED
@@ -2037,15 +2037,65 @@ function codeRead() {
2037
2037
  }
2038
2038
  if (result.saveError) console.error(' (could not save this read: ' + result.saveError + ')');
2039
2039
 
2040
- const high = result.findings.filter((f) => f.severity === 'HIGH');
2041
- const medium = result.findings.filter((f) => f.severity !== 'HIGH');
2040
+ // Secret keys first: one in the page outranks every other finding, because
2041
+ // with it none of the database's rules apply.
2042
+ const keyed = result.findings.filter((f) => f.kind === 'secret-in-code');
2043
+ const redirects = result.findings.filter((f) => f.kind === 'open-redirect');
2044
+ const html = result.findings.filter((f) => f.kind !== 'secret-in-code' && f.kind !== 'open-redirect');
2045
+ const high = html.filter((f) => f.severity === 'HIGH');
2046
+ const medium = html.filter((f) => f.severity !== 'HIGH');
2042
2047
  console.log('');
2043
- console.log(' I read ' + result.filesRead + ' file' + (result.filesRead === 1 ? '' : 's') + ' of your app\'s code.');
2044
- if (!result.findings.length) {
2048
+ console.log(' I read ' + result.filesRead + ' file' + (result.filesRead === 1 ? '' : 's') + ' of your app\'s code,');
2049
+ console.log(' for three things: secret keys written into it, redirects that go wherever');
2050
+ console.log(' a link says, and text inserted as HTML.');
2051
+ console.log('');
2052
+ if (!keyed.length) {
2053
+ console.log(' No secret key is written into your code (Supabase service_role or secret,');
2054
+ console.log(' Stripe, OpenAI, Anthropic or GitHub keys).');
2055
+ } else {
2056
+ console.log(' ' + keyed.length + ' secret key' + (keyed.length === 1 ? ' is' : 's are') +
2057
+ ' written into your code. Fix ' + (keyed.length === 1 ? 'it' : 'these') + ' first.');
2058
+ console.log(' The keys themselves are never shown or saved - only how they start.');
2059
+ for (const f of keyed) {
2060
+ console.log('');
2061
+ console.log(' ' + f.severity + ' ' + f.file + ':' + f.line + ' (verification required)');
2062
+ console.log(' ' + f.headline);
2063
+ console.log(' ' + f.sink + ': ' + f.expression);
2064
+ }
2065
+ for (const f of keyed.filter((k) => k.severity !== 'MEDIUM')) {
2066
+ console.log('');
2067
+ console.log(' ' + '-'.repeat(68));
2068
+ console.log(' Paste this into your AI tool:');
2069
+ console.log('');
2070
+ f.fixPrompt.split(String.fromCharCode(10)).forEach((l) => console.log(' ' + l));
2071
+ }
2072
+ console.log('');
2073
+ console.log(' ' + '-'.repeat(68));
2074
+ }
2075
+ console.log('');
2076
+ if (!redirects.length) {
2077
+ console.log(' No redirect sends people to an address taken from the page\'s URL.');
2078
+ } else {
2079
+ console.log(' ' + redirects.length + ' redirect' + (redirects.length === 1 ? ' goes' : 's go') +
2080
+ ' wherever the page\'s URL says (verification required):');
2081
+ for (const f of redirects) {
2082
+ console.log('');
2083
+ console.log(' ' + f.severity + ' ' + f.file + ':' + f.line);
2084
+ console.log(' ' + f.sink + ' = ' + f.expression);
2085
+ }
2086
+ console.log('');
2087
+ console.log(' Paste this into your AI tool:');
2088
+ console.log('');
2089
+ redirects[0].fixPrompt.split(String.fromCharCode(10)).forEach((l) => console.log(' ' + l));
2090
+ console.log('');
2091
+ console.log(' ' + '-'.repeat(68));
2092
+ }
2093
+ console.log('');
2094
+ if (!html.length) {
2045
2095
  console.log(' I found no place where text that could come from outside the page is');
2046
- console.log(' inserted as HTML. That is what this read looks for, and only that.');
2096
+ console.log(' inserted as HTML.');
2047
2097
  } else {
2048
- console.log(' ' + result.findings.length + ' place' + (result.findings.length === 1 ? '' : 's') +
2098
+ console.log(' ' + html.length + ' place' + (html.length === 1 ? '' : 's') +
2049
2099
  ' where text may be inserted as HTML (' + high.length + ' HIGH, ' + medium.length + ' MEDIUM).');
2050
2100
  console.log(' I did not run anything, so each one needs a look before it is called a bug.');
2051
2101
  for (const f of high.concat(medium)) {
@@ -2066,7 +2116,7 @@ function codeRead() {
2066
2116
  f.fixPrompt.split(String.fromCharCode(10)).forEach((l) => console.log(' ' + l));
2067
2117
  }
2068
2118
  }
2069
- if (result.findings.length > prompted.length) {
2119
+ if (html.length > prompted.length) {
2070
2120
  console.log('');
2071
2121
  console.log(' The fix for every one is saved in:');
2072
2122
  console.log(' ' + opened.codeFindings);
package/kryptheon-code.js CHANGED
@@ -26,6 +26,7 @@
26
26
 
27
27
  const fs = require('fs');
28
28
  const path = require('path');
29
+ const keys = require('./kryptheon-keys.js');
29
30
 
30
31
  const SKIP_DIRS = new Set([
31
32
  'node_modules', '.git', 'dist', 'build', 'out', '.next', '.nuxt', '.svelte-kit',
@@ -187,8 +188,10 @@ function classify(node, scope, code, depth, aliases) {
187
188
  const returns = returnsOf(local);
188
189
  if (returns.length) return worst(returns.map((r) => classify(r.node, r.scope, code, depth + 1, aliases)));
189
190
  }
190
- // String methods pass through whatever they were called on.
191
- if (/^(trim|toUpperCase|toLowerCase|slice|substring|substr|replace|replaceAll|padStart|padEnd|concat|toString|join)$/.test(name) &&
191
+ // String methods pass through whatever they were called on, and so do
192
+ // the array methods that only pick or reorder items: notes.filter(...)
193
+ // holds the same notes.
194
+ if (/^(trim|toUpperCase|toLowerCase|slice|substring|substr|replace|replaceAll|padStart|padEnd|concat|toString|join|filter|sort|reverse|flat|toSorted|toReversed)$/.test(name) &&
192
195
  node.callee.type === 'MemberExpression') {
193
196
  return again(node.callee.object);
194
197
  }
@@ -425,6 +428,10 @@ function isCatchParam(node, scope) {
425
428
  return !!(binding && binding.path && binding.path.type === 'CatchClause');
426
429
  }
427
430
 
431
+ // Bindings whose values are being followed right now, so a variable assigned
432
+ // from itself (count = count + 1) is not followed round in a circle.
433
+ const RESOLVING = new Set();
434
+
428
435
  /** What a name was set to, followed one step back when that says anything. */
429
436
  function fromBinding(id, scope, code, depth, aliases) {
430
437
  if (id && aliases && Object.prototype.hasOwnProperty.call(aliases, id.name)) return aliases[id.name];
@@ -434,10 +441,32 @@ function fromBinding(id, scope, code, depth, aliases) {
434
441
  if (binding.path && binding.path.type === 'CatchClause') return 'error';
435
442
  if (binding.kind === 'param') return fromCalls(binding, code, depth);
436
443
  const init = binding.path && binding.path.node && binding.path.node.init;
437
- if (!init) return 'unknown';
438
- if (readsResponseBody(init)) return 'network';
439
- // Reassigned later: the first value says nothing reliable about the last.
440
- if (binding.constantViolations && binding.constantViolations.length) return 'unknown';
444
+ const later = binding.constantViolations || [];
445
+ if (!init && !later.length) return 'unknown';
446
+ if (init && readsResponseBody(init)) return 'network';
447
+ // Reassigned later: as dangerous as the worst value it is ever given. Found
448
+ // on a blind test: `let notes = []`, then `notes = data` from the database,
449
+ // then rendered - read as "unknown" because the first value was an empty
450
+ // list. Only plain `=` and `+=` are followed; anything else - a destructure,
451
+ // a for-of - stays unknown, because what it assigns is not in one place.
452
+ if (later.length) {
453
+ if (RESOLVING.has(binding)) return 'safe'; // x = x + 1: itself adds nothing new
454
+ RESOLVING.add(binding);
455
+ try {
456
+ const origins = [];
457
+ if (init) origins.push(classify(init, binding.path.scope, code, depth + 1, aliases));
458
+ for (const v of later) {
459
+ const node = v.node;
460
+ if (node.type === 'UpdateExpression') continue; // x++ holds a number
461
+ if (node.type !== 'AssignmentExpression' || node.left.type !== 'Identifier' ||
462
+ (node.operator !== '=' && node.operator !== '+=')) return 'unknown';
463
+ origins.push(classify(node.right, v.scope, code, depth + 1, aliases));
464
+ }
465
+ return worst(origins);
466
+ } finally {
467
+ RESOLVING.delete(binding);
468
+ }
469
+ }
441
470
  // Destructured from a response body: const { name } = await res.json()
442
471
  // Aliases carry through: inside a map() callback, `const t = r.title` is as
443
472
  // dangerous as the item r it was read from.
@@ -494,6 +523,33 @@ function readsResponseBody(node) {
494
523
  const body = returnedBy(fn);
495
524
  return body && readsResponseBody(body);
496
525
  });
526
+ // Found on a blind test: const { data } = await supabase.from('notes')
527
+ // .select(...) is rows straight from the database, and was judged "unknown".
528
+ if (queriesSupabase(n)) return true;
529
+ // axios.get(url) - its .data is the server's body.
530
+ const callee = n.callee;
531
+ if (callee.type === 'Identifier' && callee.name === 'axios') return true;
532
+ if (callee.type === 'MemberExpression' && callee.object.type === 'Identifier' && callee.object.name === 'axios' &&
533
+ /^(get|post|put|patch|delete|head|request)$/.test(calleeName(callee))) return true;
534
+ return false;
535
+ }
536
+
537
+ // What a Supabase query chain does after .from('table'). Required, so that
538
+ // Array.from(x) or somebody's own .from() is not read as the database.
539
+ const QUERY_STEPS = /^(select|insert|update|upsert|delete|eq|neq|gt|gte|lt|lte|like|ilike|is|in|contains|containedBy|match|filter|or|not|order|limit|range|single|maybeSingle|textSearch|csv|throwOnError|returns|abortSignal)$/;
540
+
541
+ /** supabase.from('notes').select(...)... or supabase.rpc('fn', ...). */
542
+ function queriesSupabase(node) {
543
+ let c = node;
544
+ while (c && /CallExpression$/.test(c.type) && c.callee && /MemberExpression$/.test(c.callee.type)) {
545
+ const step = calleeName(c.callee);
546
+ const inner = c.callee.object;
547
+ if (step === 'rpc' && c.arguments[0] && /StringLiteral|TemplateLiteral/.test(c.arguments[0].type)) return true;
548
+ if (QUERY_STEPS.test(step) && inner && /CallExpression$/.test(inner.type) && calleeName(inner.callee) === 'from' &&
549
+ inner.callee.type === 'MemberExpression' && inner.arguments[0] &&
550
+ /StringLiteral|TemplateLiteral/.test(inner.arguments[0].type)) return true;
551
+ c = inner;
552
+ }
497
553
  return false;
498
554
  }
499
555
 
@@ -518,6 +574,116 @@ function paramOf(value, scope) {
518
574
  }
519
575
 
520
576
  /** Everything in one piece of code that turns a non-literal value into HTML. */
577
+ /* -------------------------- open redirects -------------------------- */
578
+ //
579
+ // Found on a blind test: after signing in, the page sent the browser to
580
+ // new URLSearchParams(location.search).get('next') - wherever a link said.
581
+ // A phishing link then lands someone on the attacker's page straight from
582
+ // the real login, and a javascript: value runs in the page. Reported only
583
+ // when the address is taken from the page's own URL, and not when the code
584
+ // checks it first: a redirect to a fixed page is the feature.
585
+
586
+ /** location, window.location, document.location. */
587
+ function isLocation(node) {
588
+ if (!node) return false;
589
+ if (node.type === 'Identifier') return node.name === 'location';
590
+ return node.type === 'MemberExpression' && !node.computed && node.property.name === 'location' &&
591
+ node.object.type === 'Identifier' && /^(window|document|self|top)$/.test(node.object.name);
592
+ }
593
+
594
+ /** Is this address taken from the page's own URL, from its start? */
595
+ function readsUrl(node, scope, depth) {
596
+ if (!node || depth > 8) return false;
597
+ const again = (n) => readsUrl(n, scope, depth + 1);
598
+ switch (node.type) {
599
+ case 'MemberExpression':
600
+ case 'OptionalMemberExpression': {
601
+ const prop = !node.computed && node.property ? node.property.name : '';
602
+ if ((prop === 'search' || prop === 'hash') && isLocation(node.object)) return true;
603
+ if (prop === 'referrer' && node.object.type === 'Identifier' && node.object.name === 'document') return true;
604
+ // Next's pages router: router.query.next
605
+ let base = node.object;
606
+ while (base && /MemberExpression$/.test(base.type)) {
607
+ if (!base.computed && base.property.name === 'query' && base.object.type === 'Identifier' && base.object.name === 'router') return true;
608
+ base = base.object;
609
+ }
610
+ return false;
611
+ }
612
+ case 'CallExpression':
613
+ case 'OptionalCallExpression': {
614
+ const name = calleeName(node.callee);
615
+ if (name === 'get' && /MemberExpression$/.test(node.callee.type)) {
616
+ const params = node.callee.object;
617
+ if (params.type === 'NewExpression' && params.callee.name === 'URLSearchParams') return true;
618
+ if (/MemberExpression$/.test(params.type) && !params.computed && params.property.name === 'searchParams') return true;
619
+ if (params.type === 'Identifier' && scope) {
620
+ const b = scope.getBinding(params.name);
621
+ const init = b && b.path && b.path.node && b.path.node.init;
622
+ if (init && init.type === 'NewExpression' && init.callee.name === 'URLSearchParams') return true;
623
+ if (init && /MemberExpression$/.test(init.type) && !init.computed && init.property.name === 'searchParams') return true;
624
+ // Next and React Router: const params = useSearchParams(), or
625
+ // const [params] = useSearchParams().
626
+ if (init && /CallExpression$/.test(init.type) && calleeName(init.callee) === 'useSearchParams') return true;
627
+ }
628
+ return false;
629
+ }
630
+ if (/^(decodeURIComponent|decodeURI|String)$/.test(name) && node.callee.type === 'Identifier') return again(node.arguments[0]);
631
+ if (/^(trim|toString|slice|substring)$/.test(name) && /MemberExpression$/.test(node.callee.type)) return again(node.callee.object);
632
+ return false; // anything else - isSafeUrl(x), sanitize(x) - is somebody's check
633
+ }
634
+ case 'LogicalExpression':
635
+ return again(node.left) || again(node.right);
636
+ case 'ConditionalExpression':
637
+ return again(node.consequent) || again(node.alternate);
638
+ case 'TemplateLiteral':
639
+ // `login.html?next=${x}` starts somewhere fixed; `${x}` does not.
640
+ return node.quasis[0].value.cooked === '' && node.expressions.length > 0 && again(node.expressions[0]);
641
+ case 'BinaryExpression':
642
+ return node.operator === '+' && again(node.left);
643
+ case 'AwaitExpression':
644
+ return again(node.argument);
645
+ case 'Identifier': {
646
+ const b = scope && scope.getBinding(node.name);
647
+ if (!b || !b.path || b.kind === 'param') return false;
648
+ const values = [];
649
+ if (b.path.node && b.path.node.init) values.push(b.path.node.init);
650
+ for (const v of b.constantViolations || []) if (v.node.type === 'AssignmentExpression') values.push(v.node.right);
651
+ return values.some((v) => readsUrl(v, b.path.scope, depth + 1));
652
+ }
653
+ default:
654
+ return false;
655
+ }
656
+ }
657
+
658
+ /**
659
+ * Does the code check this name before sending the browser there? Any `if`
660
+ * or `? :` around the redirect, or an earlier `if` in the same block, whose
661
+ * test mentions it - an allow-list, a startsWith('/'), an origin compare.
662
+ */
663
+ function checkedFirst(p, value, code) {
664
+ if (!value || value.type !== 'Identifier') return false;
665
+ const word = new RegExp('\\b' + value.name.replace(/\$/g, '\\$') + '\\b');
666
+ // A test that only asks whether there is a value - if (next) - checks
667
+ // nothing about where it goes. It has to compare or match something.
668
+ const VALIDATES = /startsWith|endsWith|\.test\(|includes|indexOf|match|origin|===|!==|allow|safe|valid|trusted/i;
669
+ const mentions = (n) => {
670
+ const text = n ? code.slice(n.start, n.end) : '';
671
+ return word.test(text) && VALIDATES.test(text);
672
+ };
673
+ let child = p;
674
+ for (let at = p.parentPath; at; child = at, at = at.parentPath) {
675
+ const n = at.node;
676
+ if ((n.type === 'IfStatement' || n.type === 'ConditionalExpression') && mentions(n.test)) return true;
677
+ if (n.type === 'LogicalExpression' && n.operator === '&&' && child.node === n.right && mentions(n.left)) return true;
678
+ if (n.type === 'BlockStatement' || n.type === 'Program') {
679
+ const index = n.body.indexOf(child.node);
680
+ if (n.body.slice(0, Math.max(0, index)).some((s) => s.type === 'IfStatement' && mentions(s.test))) return true;
681
+ }
682
+ if (/Function/.test(n.type)) break;
683
+ }
684
+ return false;
685
+ }
686
+
521
687
  function sinksIn(parser, code, file, lineOffset) {
522
688
  const found = [];
523
689
  const ast = parser.babelParse(code, file, true);
@@ -556,17 +722,35 @@ function sinksIn(parser, code, file, lineOffset) {
556
722
  },
557
723
  },
558
724
  });
725
+ const redirect = (p, sink, value) => {
726
+ if (!value || !readsUrl(value, p.scope, 0) || checkedFirst(p, value, code)) return;
727
+ found.push({ kind: 'redirect', line: lineOf(p.node), sink: sink, expression: textOf(value), origin: 'url', parts: [] });
728
+ };
559
729
  parser.traverse(ast, {
560
730
  AssignmentExpression(p) {
561
731
  const left = p.node.left;
732
+ if (isLocation(left)) return redirect(p, 'location', p.node.right);
562
733
  if (left.type !== 'MemberExpression' || left.computed) return;
563
734
  const prop = left.property && left.property.name;
564
735
  if (prop === 'innerHTML' || prop === 'outerHTML') add(p.node, p.scope, prop, p.node.right);
736
+ else if (prop === 'href' && isLocation(left.object)) redirect(p, 'location.href', p.node.right);
565
737
  },
566
738
  CallExpression(p) {
567
739
  const callee = p.node.callee;
568
740
  const name = calleeName(callee);
569
741
  const args = p.node.arguments;
742
+ if ((name === 'assign' || name === 'replace') && callee.type === 'MemberExpression' && isLocation(callee.object)) {
743
+ return redirect(p, 'location.' + name + '()', args[0]);
744
+ }
745
+ if (name === 'open' && callee.type === 'MemberExpression' && callee.object.type === 'Identifier' && callee.object.name === 'window') {
746
+ return redirect(p, 'window.open()', args[0]);
747
+ }
748
+ // Next's router follows a full URL off the site. React Router's
749
+ // navigate() does not, so it is not a sink here.
750
+ if ((name === 'push' || name === 'replace') && callee.type === 'MemberExpression' &&
751
+ callee.object.type === 'Identifier' && callee.object.name === 'router') {
752
+ return redirect(p, 'router.' + name + '()', args[0]);
753
+ }
570
754
  if (name === 'insertAdjacentHTML' && args[1]) add(p.node, p.scope, 'insertAdjacentHTML', args[1]);
571
755
  else if ((name === 'write' || name === 'writeln') && callee.type === 'MemberExpression' &&
572
756
  callee.object && callee.object.name === 'document' && args[0]) {
@@ -632,8 +816,25 @@ function scanProject(root, options) {
632
816
  }
633
817
  const findings = [];
634
818
  const unreadable = [];
819
+ // Secret keys, read from the same files. Kept apart from the HTML findings:
820
+ // they are already in their final shape, and they go first.
821
+ const secrets = [];
635
822
  let filesRead = 0;
636
- for (const full of listFiles(root)) {
823
+ const files = listFiles(root);
824
+ const relOf = (full) => path.relative(root, full).split(path.sep).join('/');
825
+ // Which pages load which scripts, so a key in config.js can be said to
826
+ // reach the browser because index.html loads it - not guessed at.
827
+ const pages = [];
828
+ for (const full of files) {
829
+ if (!/\.html?$/i.test(full)) continue;
830
+ try {
831
+ if (fs.statSync(full).size <= MAX_BYTES) pages.push({ rel: relOf(full), source: fs.readFileSync(full, 'utf8') });
832
+ } catch (err) {
833
+ /* unreadable pages are reported below */
834
+ }
835
+ }
836
+ const loadedBy = keys.scriptsLoadedBy(pages);
837
+ for (const full of files) {
637
838
  let size = 0;
638
839
  try {
639
840
  size = fs.statSync(full).size;
@@ -647,6 +848,7 @@ function scanProject(root, options) {
647
848
  }
648
849
  const source = fs.readFileSync(full, 'utf8');
649
850
  filesRead++;
851
+ for (const k of keys.secretsIn(rel, source, loadedBy)) secrets.push(k);
650
852
  const pieces = PAGE_EXT.test(full) ? scriptsIn(source) : [{ code: source, line: 1 }];
651
853
  for (const piece of pieces) {
652
854
  try {
@@ -661,7 +863,7 @@ function scanProject(root, options) {
661
863
  }
662
864
  }
663
865
  }
664
- return { ran: true, filesRead: filesRead, findings: findings.map(describe), unreadable: unreadable };
866
+ return { ran: true, filesRead: filesRead, findings: secrets.concat(findings.map(describe)), unreadable: unreadable };
665
867
  }
666
868
 
667
869
  const ORIGIN_WORDS = {
@@ -688,8 +890,45 @@ function wrap(text, width) {
688
890
  return out.join('\n');
689
891
  }
690
892
 
893
+ /** An open redirect, in the same shape as every other finding. */
894
+ function describeRedirect(f) {
895
+ const fixPrompt = [
896
+ 'My app may have an open redirect - please check it rather than assume it.',
897
+ '',
898
+ 'In ' + f.file + ' at line ' + f.line + ', the page sends the browser to an address taken from its own URL:',
899
+ '',
900
+ ' ' + f.expression,
901
+ '',
902
+ 'Anyone can send a link to this page with that part of the URL set to their own site - for example ' +
903
+ '?next=https://attacker.example. The person uses your real page, then lands on the attacker\'s, which ' +
904
+ 'can ask for their password again. A value starting with javascript: can run script in your page.',
905
+ '',
906
+ 'Fix it so only your own pages are allowed: accept the value only if it starts with a single "/" (not ' +
907
+ '"//" and not "/\\"), or check that new URL(value, location.origin).origin equals location.origin, and ' +
908
+ 'go to your home page otherwise. Then check every other place that reads a next, redirect or returnTo ' +
909
+ 'value the same way.',
910
+ ].map((p) => (p.startsWith(' ') ? p : wrap(p, 72))).join('\n');
911
+ return {
912
+ kind: 'open-redirect',
913
+ status: 'verification required',
914
+ evidence: 'code analysis',
915
+ severity: 'HIGH',
916
+ confidence: 'medium',
917
+ file: f.file,
918
+ line: f.line,
919
+ sink: f.sink,
920
+ expression: f.expression,
921
+ origin: 'url',
922
+ parts: [],
923
+ via: null,
924
+ headline: 'A redirect in ' + f.file + ':' + f.line + ' goes wherever the page\'s URL says.',
925
+ fixPrompt: fixPrompt,
926
+ };
927
+ }
928
+
691
929
  /** One finding, in the shape every Kryptheon report uses. */
692
930
  function describe(f) {
931
+ if (f.kind === 'redirect') return describeRedirect(f);
693
932
  const severity = f.origin === 'unknown' ? 'MEDIUM' : 'HIGH';
694
933
  const how = f.sink === 'v-html' ? 'v-html' : f.sink;
695
934
  const where = f.via
@@ -459,6 +459,7 @@
459
459
  meta.appendChild(sevTag(f.severity));
460
460
  meta.appendChild(statusTag(f));
461
461
  meta.appendChild(el('span', 'tag ' + (CHECK_TONE[f.check] || ''), CHECK_NAME[f.check] || f.check));
462
+ if (f.from === 'nightly' || f.from === 'both') meta.appendChild(el('span', 'tag', f.from === 'both' ? 'also nightly' : 'nightly'));
462
463
  meta.appendChild(el('span', 'f-where', f.where));
463
464
  left.appendChild(meta);
464
465
  head.appendChild(left);
@@ -564,6 +565,9 @@
564
565
  st.appendChild(el('span', 'tag ' + (STATE_TONE[c.state] || ''), c.state));
565
566
  card.appendChild(st);
566
567
  if (c.what) card.appendChild(el('div', 'c-what', c.what));
568
+ // What the nightly run inside the database last said - its own answer,
569
+ // not this terminal's, so it gets its own line.
570
+ if (c.nightly) card.appendChild(el('div', 'c-what', c.nightly));
567
571
  if (c.runnable && c.enabled) {
568
572
  var run = button('sm', 'play', 'Run now', function () { runChecks([c.id], run); });
569
573
  run.setAttribute('data-action', 'run');
@@ -600,6 +604,7 @@
600
604
  ['test-results/', 'screenshots and traces from runs that broke'],
601
605
  ['code-findings.json', 'the last read of your frontend code'],
602
606
  ['night-last.json', 'the last database check'],
607
+ ['night-nightly.json', 'what the nightly run inside your database last said'],
603
608
  ['snapshot.json', 'what your files looked like at the last look'],
604
609
  ['changes.jsonl', 'every look: what changed, what broke, what got fixed'],
605
610
  ['fixes.jsonl', 'every re-check and its verdict'],
@@ -251,6 +251,28 @@ function nextAction(findings, steps) {
251
251
  return { kind: 'quiet', title: 'Nothing open' };
252
252
  }
253
253
 
254
+ /**
255
+ * One sentence about the nightly run inside the database, or null when
256
+ * kryptheon-night has never been asked. Its four states are four different
257
+ * answers, and none of the other three may read as "nothing found": not set
258
+ * up, set up and never run, set up with nothing scheduled, and stopped.
259
+ */
260
+ function nightlyLine(nightly) {
261
+ if (!nightly) return null;
262
+ const day = (iso) => String(iso || '').slice(0, 16).replace('T', ' ');
263
+ const read = ' (as of ' + day(nightly.readAt) + ')';
264
+ if (!nightly.installed) return 'Nightly run in your database: not set up. npx kryptheon-night install sets it up.' + read;
265
+ const idle = nightly.active ? '' : ' Nothing is scheduled, so it is not running - npx kryptheon-night install sets it up again.';
266
+ if (!nightly.ranAt) return 'Nightly run in your database: set up, and it has not run yet.' + idle + read;
267
+ const when = 'Nightly run in your database, ' + day(nightly.ranAt) + ': ';
268
+ if (nightly.stopped) return when + 'it could not check - ' + nightly.stopped + idle + read;
269
+ const n = (nightly.findings || []).length;
270
+ const skipped = (nightly.notChecked || []).length;
271
+ return when + (n ? n + (n === 1 ? ' problem found.' : ' problems found.')
272
+ : 'nothing got through ' + (nightly.attacksRun || 0) + ' attacks.') +
273
+ (skipped ? ' ' + skipped + ' ' + (skipped === 1 ? 'part was' : 'parts were') + ' not tested.' : '') + idle + read;
274
+ }
275
+
254
276
  /**
255
277
  * Everything the page shows, from the store alone. Findings from every check
256
278
  * are put into one list, each carrying the same fields - severity, status,
@@ -264,6 +286,10 @@ function buildState(root, env) {
264
286
  const baselines = readJson(paths.baselines) || {};
265
287
  const code = readJson(paths.codeFindings);
266
288
  const night = readJson(paths.nightLast);
289
+ // What the nightly run inside the database last said, kept by
290
+ // kryptheon-night whenever it connects. Not the same answer as night:
291
+ // that one is the scan last run from this terminal.
292
+ const nightly = readJson(paths.nightNightly);
267
293
 
268
294
  const findings = [];
269
295
 
@@ -331,6 +357,33 @@ function buildState(root, env) {
331
357
  }
332
358
  }
333
359
 
360
+ // The nightly run's findings, said as coming from it. One both answers
361
+ // share is shown once, with the newer date - the same problem found twice
362
+ // is still one problem to fix.
363
+ if (nightly && nightly.ranAt && config.enabled.data) {
364
+ for (const f of nightly.findings || []) {
365
+ const item = {
366
+ check: 'data',
367
+ from: 'nightly',
368
+ severity: f.severity,
369
+ status: f.status || 'confirmed',
370
+ evidence: (f.status || 'confirmed') === 'confirmed' ? 'runtime confirmed' : 'code analysis',
371
+ confidence: (f.status || 'confirmed') === 'confirmed' ? 'high' : 'medium',
372
+ where: f.table + (f.column ? '.' + f.column : ''),
373
+ headline: f.headline,
374
+ detail: f.body || '',
375
+ fixPrompt: f.fixPrompt || '',
376
+ when: nightly.ranAt,
377
+ };
378
+ const same = findings.find((o) => o.check === 'data' && changes.findingKey(o) === changes.findingKey(item));
379
+ if (!same) findings.push(item);
380
+ else {
381
+ same.from = 'both';
382
+ if (!same.when || item.when > same.when) same.when = item.when;
383
+ }
384
+ }
385
+ }
386
+
334
387
  const rank = { 'HIGH CRITICAL': 0, CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
335
388
  findings.sort((a, b) => (rank[a.severity] ?? 9) - (rank[b.severity] ?? 9) ||
336
389
  (a.status === 'confirmed' ? 0 : 1) - (b.status === 'confirmed' ? 0 : 1));
@@ -359,9 +412,14 @@ function buildState(root, env) {
359
412
  else if (!config.enabled[c.id]) state = 'switched off';
360
413
  else if (c.id === 'regression') state = !recordings ? 'no recordings yet' : !lastRun ? 'never run' : lastRun.failed ? 'problems found' : 'last run held';
361
414
  else if (c.id === 'frontend') state = !code ? 'never run' : (code.findings || []).length ? 'things to check' : 'nothing found';
362
- else if (c.id === 'data') state = !night ? 'never run here' : (night.findings || []).length ? 'problems found' : 'last run held';
415
+ else if (c.id === 'data') {
416
+ const ranNightly = nightly && nightly.ranAt && !nightly.stopped;
417
+ state = !night && !ranNightly ? 'never run here'
418
+ : findings.some((f) => f.check === 'data') ? 'problems found' : 'last run held';
419
+ }
363
420
  return {
364
421
  id: c.id, label: c.label, available: c.available, enabled: config.enabled[c.id], state: state, what: c.what || '',
422
+ nightly: c.id === 'data' ? nightlyLine(nightly) : null,
365
423
  // Runnable from the page: what needs nothing the page cannot supply. The
366
424
  // database check needs a connection string, which stays in the terminal.
367
425
  runnable: c.available && (c.id === 'frontend' || (c.id === 'regression' && recordings > 0)),
@@ -372,7 +430,7 @@ function buildState(root, env) {
372
430
  codeAt: code ? code.checkedAt : null,
373
431
  recordings: recordings,
374
432
  lastRunAt: lastRun ? lastRun.runAt : null,
375
- nightAt: night ? nightWhen : null,
433
+ nightAt: [night ? nightWhen : null, nightly && nightly.ranAt].filter(Boolean).sort().pop() || null,
376
434
  });
377
435
 
378
436
  // Every look Kryptheon took - by `verify`, or by the dashboard's own watch -
@@ -0,0 +1,320 @@
1
+ // Reads a project's code for keys that must never be in it.
2
+ //
3
+ // One key does more damage than any other mistake in an app built on
4
+ // Supabase: the service_role key. It skips every row level security rule, so
5
+ // whoever holds it can read, change and delete every row in every table. Put
6
+ // in code that the browser downloads, it belongs to anyone who opens the
7
+ // developer tools. Found on a blind test: a config.js fallback held one, and
8
+ // every rule the database attack checked was beside the point - with that key
9
+ // in the page, none of them apply.
10
+ //
11
+ // This is a read of the files, never a use of what is found: nothing is sent
12
+ // anywhere and no key is tried. The key itself is never printed, saved or put
13
+ // in a prompt - only its first characters and its length, enough for a person
14
+ // to find it and too little for anyone to use it.
15
+ //
16
+ // Narrow on purpose, as the HTML read is. Only formats that are secret by
17
+ // construction are reported as certain. Keys that are public by design - the
18
+ // Supabase anon or publishable key, a Firebase web key - are never reported.
19
+
20
+ const path = require('path');
21
+
22
+ // A variable the build copies into the browser bundle, named as a secret.
23
+ // Next, Vite, Create React App, Expo, Nuxt, SvelteKit and Gatsby all inline
24
+ // anything with these prefixes, so the value ships whatever the file is.
25
+ const PUBLIC_SECRET_NAME = /\b(NEXT_PUBLIC|VITE|REACT_APP|EXPO_PUBLIC|NUXT_PUBLIC|GATSBY|PUBLIC)_[A-Z0-9_]*(?:SERVICE_ROLE|SECRET)[A-Z0-9_]*\b/g;
26
+
27
+ const SUPABASE_ROTATE = 'in the Supabase dashboard, open Project Settings, then API Keys, and roll the ' +
28
+ 'service_role (secret) key';
29
+ const SUPABASE_OPENS = 'It skips every row level security rule: anyone who has it can read, change ' +
30
+ 'and delete every row in every table, and act as any user.';
31
+ const SUPABASE_INSTEAD = 'the anon (publishable) key, with row level security doing the protecting';
32
+
33
+ // Formats that are a secret whatever they are called. Each needs a real
34
+ // length after its prefix, so a placeholder like "sb_secret_..." is not one.
35
+ const FORMATS = [
36
+ {
37
+ re: /\bsb_secret_[A-Za-z0-9_-]{20,}/g,
38
+ label: 'Supabase secret key',
39
+ opens: SUPABASE_OPENS,
40
+ rotate: SUPABASE_ROTATE,
41
+ instead: SUPABASE_INSTEAD,
42
+ },
43
+ {
44
+ re: /\b[sr]k_live_[A-Za-z0-9]{20,}/g,
45
+ label: 'Stripe live secret key',
46
+ opens: 'It can charge cards, issue refunds and read every customer and payment in your Stripe account.',
47
+ rotate: 'in the Stripe dashboard, open Developers, then API keys, and roll it',
48
+ instead: 'the publishable key (pk_live_...), with every charge made from your server',
49
+ },
50
+ {
51
+ re: /\b[sr]k_test_[A-Za-z0-9]{20,}/g,
52
+ label: 'Stripe test secret key',
53
+ opens: 'It controls your Stripe test account. It moves no real money, but a live key in the same place would.',
54
+ rotate: 'in the Stripe dashboard, open Developers, then API keys, and roll it',
55
+ instead: 'the publishable key (pk_test_...), with every charge made from your server',
56
+ },
57
+ {
58
+ re: /\bsk-ant-[A-Za-z0-9_-]{32,}/g,
59
+ label: 'Anthropic API key',
60
+ opens: 'Anyone who has it can run requests on your account and your bill.',
61
+ rotate: 'in the Anthropic Console, open API Keys, and delete it',
62
+ instead: 'a call to your own server, which holds the key',
63
+ },
64
+ {
65
+ re: /\bsk-(?!ant-)(?:proj-|svcacct-)?[A-Za-z0-9_-]{32,}/g,
66
+ label: 'OpenAI API key',
67
+ opens: 'Anyone who has it can run requests on your account and your bill.',
68
+ rotate: 'in the OpenAI dashboard, open API keys, and revoke it',
69
+ instead: 'a call to your own server, which holds the key',
70
+ },
71
+ {
72
+ re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})/g,
73
+ label: 'GitHub token',
74
+ opens: 'It acts as you on GitHub, with whatever access it was given - often every repository you have.',
75
+ rotate: 'on GitHub, open Settings, then Developer settings, then Personal access tokens, and delete it',
76
+ instead: 'nothing - a GitHub token has no business in app code at all',
77
+ },
78
+ ];
79
+
80
+ const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g;
81
+
82
+ /** The claims of a JWT, or null when it is not one this can read. */
83
+ function claimsOf(token) {
84
+ try {
85
+ const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
86
+ const claims = JSON.parse(Buffer.from(part, 'base64').toString('utf8'));
87
+ return claims && typeof claims === 'object' ? claims : null;
88
+ } catch (err) {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ // A quoted value that is shaped like a key and says it is a service-role one,
94
+ // in a format this does not know. Digits are required: "YOUR_SERVICE_ROLE_KEY"
95
+ // is somebody's placeholder, not a key. Reported as unconfirmed, because the
96
+ // format cannot say whether it is real.
97
+ const QUOTED = /(["'`])([A-Za-z0-9_.-]{24,})\1/g;
98
+ function looksLikeServiceRoleKey(value) {
99
+ if (!/service[_-]?role/i.test(value)) return false;
100
+ if (/your|example|placeholder|changeme|x{4,}|here/i.test(value)) return false;
101
+ return (value.match(/[0-9]/g) || []).length >= 4;
102
+ }
103
+
104
+ /** First characters and length - enough to find it, too little to use it. */
105
+ function masked(value) {
106
+ return value.slice(0, 6) + '... (' + value.length + ' characters, not shown)';
107
+ }
108
+
109
+ // Wrapped here, as the HTML read's prompts are: pasted into a chat box as
110
+ // often as read in a terminal, and a long line is a wall in both.
111
+ function wrap(text, width) {
112
+ const out = [];
113
+ let current = '';
114
+ for (const word of String(text).split(' ')) {
115
+ if (current && (current + ' ' + word).length > width) {
116
+ out.push(current);
117
+ current = word;
118
+ } else {
119
+ current = current ? current + ' ' + word : word;
120
+ }
121
+ }
122
+ out.push(current);
123
+ return out.join('\n');
124
+ }
125
+
126
+ function lineAt(source, index) {
127
+ let line = 1;
128
+ for (let i = 0; i < index; i++) if (source.charCodeAt(i) === 10) line++;
129
+ return line;
130
+ }
131
+
132
+ // Where a file runs. A page, a file a page loads, or code that touches the
133
+ // document is browser code; a file under api/, server/ or functions/, or one
134
+ // that starts a server, is not. Anything else is said as it is: unknown.
135
+ const SERVER_PATH = /(^|\/)(api|server|backend|functions|scripts|cron|workers?)\//i;
136
+ const SERVER_CODE = /['"]use server['"]|require\(\s*['"](express|fastify|koa|http)['"]\s*\)|from\s+['"](express|fastify|koa)['"]|Deno\.serve\s*\(/;
137
+ const BROWSER_CODE = /\b(?:document|window|localStorage|sessionStorage)\s*\.|['"]use client['"]|\buse(?:State|Effect)\s*\(/;
138
+
139
+ function whereItRuns(rel, source, loadedBy) {
140
+ if (/\.(html?|vue|svelte)$/i.test(rel)) return { browser: true, why: rel + ' is a page, so every visitor downloads it' };
141
+ if (loadedBy && loadedBy.get(rel)) {
142
+ return { browser: true, why: loadedBy.get(rel) + ' loads ' + rel + ', so every visitor downloads it' };
143
+ }
144
+ if (SERVER_PATH.test(rel) || SERVER_CODE.test(source)) return { browser: false, why: null };
145
+ if (BROWSER_CODE.test(source)) return { browser: true, why: rel + ' runs in the browser' };
146
+ return { browser: null, why: null };
147
+ }
148
+
149
+ /**
150
+ * Which pages load which scripts: <script src="..."> read out of every page,
151
+ * resolved against the page's folder. A full URL is skipped - it is not ours.
152
+ */
153
+ function scriptsLoadedBy(pages) {
154
+ const loaded = new Map();
155
+ for (const page of pages) {
156
+ const re = /<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi;
157
+ let m;
158
+ while ((m = re.exec(page.source))) {
159
+ const src = m[1].split('?')[0].split('#')[0];
160
+ if (/^[a-z]+:|^\/\//i.test(src)) continue;
161
+ const rel = src.startsWith('/')
162
+ ? path.posix.normalize(src.slice(1))
163
+ : path.posix.normalize(path.posix.join(path.posix.dirname(page.rel), src));
164
+ if (!loaded.has(rel)) loaded.set(rel, page.rel);
165
+ }
166
+ }
167
+ return loaded;
168
+ }
169
+
170
+ function keyFinding(rel, source, index, value, kind, confidence, runs) {
171
+ const line = lineAt(source, index);
172
+ const severity = kind.severity || (runs.browser === false ? 'HIGH' : runs.browser ? 'CRITICAL' : 'HIGH');
173
+ const shown = masked(value);
174
+ const a = confidence === 'high' ? 'A ' : 'What looks like a ';
175
+ const f = {
176
+ kind: 'secret-in-code',
177
+ status: 'verification required',
178
+ evidence: 'code analysis',
179
+ severity: severity,
180
+ confidence: confidence,
181
+ file: rel,
182
+ line: line,
183
+ sink: 'secret key',
184
+ expression: shown,
185
+ origin: 'code',
186
+ parts: [],
187
+ via: null,
188
+ headline: a + kind.label + ' is written into ' + rel + ':' + line +
189
+ (runs.browser ? ', and it reaches the browser.'
190
+ : runs.browser === false ? ' (server code - still in every copy of the repository).' : '.'),
191
+ };
192
+ f.fixPrompt = [
193
+ 'My app has a secret key written into its code. Please fix it - and do not paste the key back to me.',
194
+ '',
195
+ 'In ' + rel + ' at line ' + line + ' there is ' + a.toLowerCase() + kind.label +
196
+ ' (it starts with "' + value.slice(0, 6) + '").' + (runs.why ? ' ' + runs.why + '.' : ''),
197
+ kind.opens,
198
+ '',
199
+ '1. Rotate it first: ' + kind.rotate + '. Taking it out of the code is not enough - it is already in ' +
200
+ 'the deployed site and in the git history.',
201
+ '2. Remove it from this file and from anything that reads it. Code that runs in the browser should only ' +
202
+ 'ever use ' + kind.instead + '.',
203
+ '3. Anything that truly needs the secret moves to server code - an API route or a Supabase Edge Function - ' +
204
+ 'reading it from an environment variable with no public prefix (not NEXT_PUBLIC_, VITE_ or REACT_APP_).',
205
+ '4. Search the whole project, config files and HTML included, for any other copy of it.',
206
+ ].map((l) => wrap(l, 72)).join('\n');
207
+ return f;
208
+ }
209
+
210
+ function publicNameFinding(rel, source, index, name, prefix) {
211
+ const line = lineAt(source, index);
212
+ const supabase = /SUPABASE|SERVICE_ROLE/.test(name);
213
+ return {
214
+ kind: 'secret-in-code',
215
+ status: 'verification required',
216
+ evidence: 'code analysis',
217
+ severity: 'CRITICAL',
218
+ confidence: 'high',
219
+ file: rel,
220
+ line: line,
221
+ sink: 'secret in a public variable',
222
+ expression: name,
223
+ origin: 'code',
224
+ parts: [],
225
+ via: null,
226
+ headline: rel + ':' + line + ' reads ' + name + ' - every variable starting ' + prefix +
227
+ ' is copied into the browser, so the secret ships with the site.',
228
+ fixPrompt: [
229
+ 'My app puts a secret into the browser through an environment variable. Please fix it - and do not ' +
230
+ 'paste the value back to me.',
231
+ '',
232
+ 'In ' + rel + ' at line ' + line + ' the code reads ' + name + '. The build copies every variable ' +
233
+ 'starting ' + prefix + ' into the JavaScript each visitor downloads, whatever file reads it.',
234
+ supabase ? SUPABASE_OPENS : 'Whoever opens the browser tools has the secret.',
235
+ '',
236
+ '1. Rotate the secret first' + (supabase ? ': ' + SUPABASE_ROTATE : '') + '. The built site already contains it.',
237
+ '2. Rename the variable without the public prefix and read it only in server code - an API route or a ' +
238
+ 'Supabase Edge Function.',
239
+ '3. Code that runs in the browser should only ever use ' +
240
+ (supabase ? SUPABASE_INSTEAD : 'values that are safe to publish') + '.',
241
+ '4. Search the whole project for every other place that reads it.',
242
+ ].map((l) => wrap(l, 72)).join('\n'),
243
+ };
244
+ }
245
+
246
+ /** Every secret in one file. `loadedBy` maps a script to a page that loads it. */
247
+ function secretsIn(rel, source, loadedBy) {
248
+ const out = [];
249
+ const seen = new Set();
250
+ const runs = whereItRuns(rel, source, loadedBy);
251
+ const add = (index, value, kind, confidence) => {
252
+ if (seen.has(value)) return;
253
+ seen.add(value);
254
+ out.push(keyFinding(rel, source, index, value, kind, confidence, runs));
255
+ };
256
+
257
+ for (const format of FORMATS) {
258
+ format.re.lastIndex = 0;
259
+ let m;
260
+ while ((m = format.re.exec(source))) add(m.index, m[0], format, 'high');
261
+ }
262
+
263
+ JWT.lastIndex = 0;
264
+ let j;
265
+ while ((j = JWT.exec(source))) {
266
+ const claims = claimsOf(j[0]);
267
+ if (!claims || claims.role !== 'service_role') continue; // anon and user tokens are public by design
268
+ // Supabase's local development key is the same published value on every
269
+ // machine: harmless against a laptop, but a sign of where a real one goes.
270
+ const demo = claims.iss === 'supabase-demo';
271
+ add(j.index, j[0], demo
272
+ ? {
273
+ label: 'Supabase service_role key (the published local-development one)',
274
+ opens: 'Against a laptop it does no harm. But this is the place a real project\'s service_role key ' +
275
+ 'would go, and there it would open every table to anyone.',
276
+ rotate: 'nothing to rotate for the local key itself - check that no real key is used the same way',
277
+ instead: SUPABASE_INSTEAD,
278
+ severity: 'MEDIUM',
279
+ }
280
+ : { label: 'Supabase service_role key', opens: SUPABASE_OPENS, rotate: SUPABASE_ROTATE, instead: SUPABASE_INSTEAD },
281
+ 'high');
282
+ }
283
+
284
+ QUOTED.lastIndex = 0;
285
+ let q;
286
+ while ((q = QUOTED.exec(source))) {
287
+ if (!looksLikeServiceRoleKey(q[2])) continue;
288
+ add(q.index + 1, q[2], {
289
+ label: 'Supabase service_role key',
290
+ opens: SUPABASE_OPENS + ' Its format is not one I know, so check whether it is a real key.',
291
+ rotate: SUPABASE_ROTATE,
292
+ instead: SUPABASE_INSTEAD,
293
+ }, 'medium');
294
+ }
295
+
296
+ // Only where it is read from the environment, and never in a test: found on
297
+ // Kryptheon's own code, a constant called PUBLIC_SECRET_NAME is a name in a
298
+ // program, not a variable the build ships - and a test file ships nothing.
299
+ const isTest = /(^|\/)__tests__\/|\.(test|spec|check)\.[cm]?[jt]sx?$/i.test(rel);
300
+ const svelteEnv = /from\s+['"]\$env\/(static|dynamic)\/public['"]/.test(source);
301
+ PUBLIC_SECRET_NAME.lastIndex = 0;
302
+ let n;
303
+ while (!isTest && (n = PUBLIC_SECRET_NAME.exec(source))) {
304
+ const before = source.slice(Math.max(0, n.index - 40), n.index);
305
+ const read = /(process\.env|import\.meta\.env|\benv)\s*(\.|\[\s*['"])$/.test(before) || svelteEnv;
306
+ if (!read || seen.has(n[0])) continue;
307
+ seen.add(n[0]);
308
+ out.push(publicNameFinding(rel, source, n.index, n[0], n[1] + '_'));
309
+ }
310
+
311
+ return out;
312
+ }
313
+
314
+ module.exports = {
315
+ secretsIn: secretsIn,
316
+ scriptsLoadedBy: scriptsLoadedBy,
317
+ whereItRuns: whereItRuns,
318
+ masked: masked,
319
+ claimsOf: claimsOf,
320
+ };
@@ -142,18 +142,25 @@ function inspectProject(dir, env) {
142
142
  * on a blind test: exactly such an app was refused, and nothing was read. The
143
143
  * refusal exists so that a check does not run somebody else's recordings;
144
144
  * reading the files in front of you cannot do that. A page at the top of the
145
- * folder is what says it is a site. A home or system folder stays refused.
145
+ * folder, or in one of the folders a site's pages usually live in, is what
146
+ * says it is a site - found on the next blind test, where the pages were in
147
+ * src/ and the read refused at the project's root. A home or system folder
148
+ * stays refused.
146
149
  */
150
+ const PAGE_FOLDERS = ['', 'src', 'public', 'www', 'site', 'static', 'web', 'client', 'frontend'];
151
+
147
152
  function inspectCodeFolder(dir, env) {
148
153
  const here = inspectProject(dir, env);
149
154
  if (here.ok || here.reason !== 'no-package') return here;
150
- let pages = [];
151
- try {
152
- pages = fs.readdirSync(dir).filter((name) => /\.html?$/i.test(name) && fs.statSync(path.join(dir, name)).isFile());
153
- } catch (e) {
154
- pages = [];
155
- }
156
- return pages.length ? Object.assign({}, here, { ok: true, reason: null, plainSite: true }) : here;
155
+ const hasPage = PAGE_FOLDERS.some((sub) => {
156
+ try {
157
+ const at = path.join(dir, sub);
158
+ return fs.readdirSync(at).some((name) => /\.html?$/i.test(name) && fs.statSync(path.join(at, name)).isFile());
159
+ } catch (e) {
160
+ return false;
161
+ }
162
+ });
163
+ return hasPage ? Object.assign({}, here, { ok: true, reason: null, plainSite: true }) : here;
157
164
  }
158
165
 
159
166
  /**
@@ -45,6 +45,7 @@ const FILES = {
45
45
  history: 'history.jsonl',
46
46
  testResults: 'test-results',
47
47
  nightLast: 'night-last.json',
48
+ nightNightly: 'night-nightly.json',
48
49
  codeFindings: 'code-findings.json',
49
50
  config: 'config.json',
50
51
  snapshot: 'snapshot.json',
@@ -132,6 +133,7 @@ function pathsIn(dir) {
132
133
  history: path.join(dir, FILES.history),
133
134
  testResults: path.join(dir, FILES.testResults),
134
135
  nightLast: path.join(dir, FILES.nightLast),
136
+ nightNightly: path.join(dir, FILES.nightNightly),
135
137
  codeFindings: path.join(dir, FILES.codeFindings),
136
138
  config: path.join(dir, FILES.config),
137
139
  snapshot: path.join(dir, FILES.snapshot),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "Record what you do in your app and get plain-language reports when it breaks.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -33,6 +33,7 @@
33
33
  "kryptheon-project.js",
34
34
  "kryptheon-store.js",
35
35
  "kryptheon-code.js",
36
+ "kryptheon-keys.js",
36
37
  "kryptheon-dashboard.js",
37
38
  "kryptheon-dashboard.html",
38
39
  "kryptheon-dashboard-page.js",
@@ -50,7 +51,7 @@
50
51
  "@playwright/test": "^1.62.1"
51
52
  },
52
53
  "scripts": {
53
- "check": "node kryptheon-store.check.js && node kryptheon-code.check.js && node kryptheon-dashboard.check.js && node kryptheon-changes.check.js && node kryptheon-look.check.js && node kryptheon-verify.check.js && node kryptheon-recheck.check.js && node kryptheon-reporter.check.js && node kryptheon-signature.check.js && node kryptheon-signature.stream.check.js && node kryptheon-signature.baseline.check.js && node kryptheon-signature.settle.check.js && node kryptheon-signature.number.check.js && node kryptheon-env.check.js && node kryptheon-watchdog.check.js && node kryptheon-record.check.js && node kryptheon-selectors.check.js && node kryptheon-recordings.check.js && node kryptheon-cleanup.check.js && node kryptheon-orphan.check.js && node kryptheon-project.check.js && node kryptheon-setup.check.js && node kryptheon-browser.check.js && node kryptheon-heal.check.js && node kryptheon-load.check.js && node kryptheon-package.check.js",
54
+ "check": "node kryptheon-store.check.js && node kryptheon-code.check.js && node kryptheon-keys.check.js && node kryptheon-dashboard.check.js && node kryptheon-changes.check.js && node kryptheon-look.check.js && node kryptheon-verify.check.js && node kryptheon-recheck.check.js && node kryptheon-reporter.check.js && node kryptheon-signature.check.js && node kryptheon-signature.stream.check.js && node kryptheon-signature.baseline.check.js && node kryptheon-signature.settle.check.js && node kryptheon-signature.number.check.js && node kryptheon-env.check.js && node kryptheon-watchdog.check.js && node kryptheon-record.check.js && node kryptheon-selectors.check.js && node kryptheon-recordings.check.js && node kryptheon-cleanup.check.js && node kryptheon-orphan.check.js && node kryptheon-project.check.js && node kryptheon-setup.check.js && node kryptheon-browser.check.js && node kryptheon-heal.check.js && node kryptheon-load.check.js && node kryptheon-package.check.js",
54
55
  "check:store": "node kryptheon-store.check.js",
55
56
  "check:code": "node kryptheon-code.check.js",
56
57
  "check:dashboard": "node kryptheon-dashboard.check.js",