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 +57 -7
- package/kryptheon-code.js +247 -8
- package/kryptheon-dashboard-page.js +5 -0
- package/kryptheon-dashboard.js +60 -2
- package/kryptheon-keys.js +320 -0
- package/kryptheon-project.js +15 -8
- package/kryptheon-store.js +2 -0
- package/package.json +3 -2
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
|
-
|
|
2041
|
-
|
|
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
|
-
|
|
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.
|
|
2096
|
+
console.log(' inserted as HTML.');
|
|
2047
2097
|
} else {
|
|
2048
|
-
console.log(' ' +
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
438
|
-
if (
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
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'],
|
package/kryptheon-dashboard.js
CHANGED
|
@@ -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')
|
|
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
|
+
};
|
package/kryptheon-project.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
/**
|
package/kryptheon-store.js
CHANGED
|
@@ -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.
|
|
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",
|