kryptheon 0.1.19 → 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -10
- package/bin/kryptheon.js +466 -6
- package/kryptheon-changes.js +222 -0
- package/kryptheon-code.js +564 -0
- package/kryptheon-dashboard-page.js +1003 -0
- package/kryptheon-dashboard.html +446 -0
- package/kryptheon-dashboard.js +532 -0
- package/kryptheon-fixture.js +7 -3
- package/kryptheon-logo.png +0 -0
- package/kryptheon-look.js +259 -0
- package/kryptheon-reporter.js +18 -6
- package/kryptheon-secrets.js +5 -3
- package/kryptheon-store.js +332 -0
- package/package.json +93 -78
- package/playwright.config.js +5 -2
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
// Reads a project's frontend code for one kind of mistake: text that may come
|
|
2
|
+
// from outside the page - a server response, an error message, a value a user
|
|
3
|
+
// typed - being handed to something that turns text into HTML.
|
|
4
|
+
//
|
|
5
|
+
// element.innerHTML = `<p>${data.name}</p>`
|
|
6
|
+
//
|
|
7
|
+
// If data.name holds "<img src=x onerror=...>", the browser runs it in the
|
|
8
|
+
// page, with the page's session. Written as textContent, or escaped first, the
|
|
9
|
+
// same value is just text.
|
|
10
|
+
//
|
|
11
|
+
// This is code analysis, never execution: nothing is run, nothing is sent, and
|
|
12
|
+
// every finding is "verification required". A static read cannot see what a
|
|
13
|
+
// value holds at run time, so it says where the risk is and how sure it is,
|
|
14
|
+
// and leaves the verdict to a person or a later runtime check.
|
|
15
|
+
//
|
|
16
|
+
// Deliberately narrow, because a report that flags every innerHTML in an app
|
|
17
|
+
// is a report nobody reads twice:
|
|
18
|
+
// - a sink fed only literal text is safe and never reported;
|
|
19
|
+
// - a value wrapped in an escaping or sanitising call is safe;
|
|
20
|
+
// - a value that can only be a number is safe;
|
|
21
|
+
// - everything else is reported, and ranked by where it came from.
|
|
22
|
+
//
|
|
23
|
+
// The parser is the one Playwright already ships, so this adds no dependency.
|
|
24
|
+
// It is an internal file of Playwright's, not a public API; if a later
|
|
25
|
+
// Playwright moves it, this says it could not run - it never says "passed".
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
|
|
30
|
+
const SKIP_DIRS = new Set([
|
|
31
|
+
'node_modules', '.git', 'dist', 'build', 'out', '.next', '.nuxt', '.svelte-kit',
|
|
32
|
+
'coverage', 'vendor', '.vercel', '.netlify', '.turbo', '.cache', 'tests', 'test-results',
|
|
33
|
+
]);
|
|
34
|
+
const CODE_EXT = /\.(js|mjs|cjs|jsx|ts|tsx)$/i;
|
|
35
|
+
const PAGE_EXT = /\.(html|htm|vue|svelte)$/i;
|
|
36
|
+
const MAX_BYTES = 800 * 1024;
|
|
37
|
+
|
|
38
|
+
// A call whose name says it makes text safe for HTML. Matched on the called
|
|
39
|
+
// name only - escapeHtml(x), DOMPurify.sanitize(x), he.encode(x).
|
|
40
|
+
const MAKES_SAFE = /^(escape\w*|\w*escape|sanitize\w*|sanitise\w*|\w*sanitize|purify|encode\w*|\w*encode|htmlEncode|safeHtml|clean(Html)?|textToHtml)$/i;
|
|
41
|
+
|
|
42
|
+
// Calls and properties whose result can only be a number, a boolean, or a
|
|
43
|
+
// formatted number/date - nothing that can carry a tag.
|
|
44
|
+
const NUMERIC_CALLS = /^(Number|parseInt|parseFloat|Boolean|isNaN|isFinite|toFixed|toPrecision|toLocaleString|toLocaleDateString|toLocaleTimeString|getTime|getFullYear|getMonth|getDate|getDay|getHours|getMinutes|getSeconds|round|floor|ceil|abs|min|max|indexOf|findIndex|size)$/;
|
|
45
|
+
const NUMERIC_PROPS = /^(length|size|count|total|id|index|width|height|top|left|x|y)$/;
|
|
46
|
+
|
|
47
|
+
/** Every file worth reading, relative paths, skipping what is not the app's source. */
|
|
48
|
+
function listFiles(root) {
|
|
49
|
+
const out = [];
|
|
50
|
+
(function walk(dir) {
|
|
51
|
+
let entries;
|
|
52
|
+
try {
|
|
53
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (entry.name.startsWith('.') && entry.name !== '.') {
|
|
59
|
+
if (entry.isDirectory()) continue;
|
|
60
|
+
}
|
|
61
|
+
const full = path.join(dir, entry.name);
|
|
62
|
+
if (entry.isDirectory()) {
|
|
63
|
+
if (!SKIP_DIRS.has(entry.name)) walk(full);
|
|
64
|
+
} else if ((CODE_EXT.test(entry.name) || PAGE_EXT.test(entry.name)) && !/\.min\.js$/i.test(entry.name)) {
|
|
65
|
+
out.push(full);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
})(root);
|
|
69
|
+
return out.sort();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The parser, or null when this Playwright does not have it where expected. */
|
|
73
|
+
function loadParser(packageDir) {
|
|
74
|
+
const candidates = [];
|
|
75
|
+
try {
|
|
76
|
+
candidates.push(path.join(path.dirname(require.resolve('playwright/package.json', { paths: [packageDir] })), 'lib', 'transform', 'babelBundle.js'));
|
|
77
|
+
} catch (err) {
|
|
78
|
+
/* not resolvable from here */
|
|
79
|
+
}
|
|
80
|
+
candidates.push(path.join(packageDir, 'node_modules', 'playwright', 'lib', 'transform', 'babelBundle.js'));
|
|
81
|
+
for (const file of candidates) {
|
|
82
|
+
try {
|
|
83
|
+
const bundle = require(file);
|
|
84
|
+
if (typeof bundle.babelParse === 'function' && typeof bundle.traverse === 'function') return bundle;
|
|
85
|
+
} catch (err) {
|
|
86
|
+
/* try the next */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Inline <script> blocks of a page, with the line each one starts on. */
|
|
93
|
+
function scriptsIn(source) {
|
|
94
|
+
const blocks = [];
|
|
95
|
+
const re = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
|
|
96
|
+
let m;
|
|
97
|
+
while ((m = re.exec(source))) {
|
|
98
|
+
const attrs = m[1] || '';
|
|
99
|
+
if (/\bsrc\s*=/.test(attrs)) continue;
|
|
100
|
+
if (/type\s*=\s*["']?(application\/(ld\+)?json|text\/template|importmap)/i.test(attrs)) continue;
|
|
101
|
+
const before = source.slice(0, m.index + m[0].indexOf('>') + 1);
|
|
102
|
+
blocks.push({ code: m[2], line: before.split('\n').length });
|
|
103
|
+
}
|
|
104
|
+
return blocks;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** v-html="..." in a template: always a sink, safe only when the value is quoted text. */
|
|
108
|
+
function vHtmlIn(source) {
|
|
109
|
+
const found = [];
|
|
110
|
+
const re = /\bv-html\s*=\s*"([^"]*)"/g;
|
|
111
|
+
let m;
|
|
112
|
+
while ((m = re.exec(source))) {
|
|
113
|
+
const value = m[1].trim();
|
|
114
|
+
if (/^'[^']*'$/.test(value)) continue;
|
|
115
|
+
found.push({ line: source.slice(0, m.index).split('\n').length, expression: value });
|
|
116
|
+
}
|
|
117
|
+
return found;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function calleeName(node) {
|
|
121
|
+
if (!node) return '';
|
|
122
|
+
if (node.type === 'Identifier') return node.name;
|
|
123
|
+
if (node.type === 'MemberExpression' && !node.computed && node.property) return node.property.name || '';
|
|
124
|
+
return '';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Where a value comes from, as far as the code shows.
|
|
129
|
+
* 'safe' - literal text, a number, or made safe by an escaping call
|
|
130
|
+
* 'network' - read from a response body (.json()), so a server wrote it
|
|
131
|
+
* 'error' - an error's message, which often carries text a server sent
|
|
132
|
+
* 'unknown' - anything else: it may be fine, the code alone cannot say
|
|
133
|
+
*/
|
|
134
|
+
// How far one value is followed back before giving up and calling it unknown.
|
|
135
|
+
// It only exists to stop a loop - a function that returns itself, a name that
|
|
136
|
+
// feeds another. It was 6, which a constant-only lookup (a template, a call,
|
|
137
|
+
// its return, a map of maps) used up before reaching the strings at the
|
|
138
|
+
// bottom, so a harmless label came back "unknown". Giving up always errs
|
|
139
|
+
// towards reporting, never towards silence.
|
|
140
|
+
const MAX_DEPTH = 12;
|
|
141
|
+
|
|
142
|
+
function classify(node, scope, code, depth, aliases) {
|
|
143
|
+
if (!node || depth > MAX_DEPTH) return 'unknown';
|
|
144
|
+
const again = (n) => classify(n, scope, code, depth + 1, aliases);
|
|
145
|
+
switch (node.type) {
|
|
146
|
+
case 'StringLiteral':
|
|
147
|
+
case 'NumericLiteral':
|
|
148
|
+
case 'BooleanLiteral':
|
|
149
|
+
case 'NullLiteral':
|
|
150
|
+
return 'safe';
|
|
151
|
+
case 'TemplateLiteral':
|
|
152
|
+
return worst(node.expressions.map((e) => again(e)));
|
|
153
|
+
case 'BinaryExpression':
|
|
154
|
+
if (node.operator !== '+') return 'safe'; // - * / % comparisons: never text
|
|
155
|
+
return worst([again(node.left), again(node.right)]);
|
|
156
|
+
case 'UnaryExpression':
|
|
157
|
+
case 'UpdateExpression':
|
|
158
|
+
return 'safe';
|
|
159
|
+
case 'ConditionalExpression':
|
|
160
|
+
return worst([again(node.consequent), again(node.alternate)]);
|
|
161
|
+
case 'LogicalExpression':
|
|
162
|
+
return worst([again(node.left), again(node.right)]);
|
|
163
|
+
case 'CallExpression':
|
|
164
|
+
case 'OptionalCallExpression': {
|
|
165
|
+
const name = calleeName(node.callee);
|
|
166
|
+
if (MAKES_SAFE.test(name)) return 'safe';
|
|
167
|
+
if (readsResponseBody(node)) return 'network';
|
|
168
|
+
if (NUMERIC_CALLS.test(name)) return 'safe';
|
|
169
|
+
if (node.callee.type === 'MemberExpression' && node.callee.object && node.callee.object.name === 'Math') return 'safe';
|
|
170
|
+
// items.map(i => `<li>${i.name}</li>`).join('') - judge what map builds.
|
|
171
|
+
if (name === 'join' && node.callee.object && /CallExpression$/.test(node.callee.object.type) &&
|
|
172
|
+
calleeName(node.callee.object.callee) === 'map') {
|
|
173
|
+
const fn = node.callee.object.arguments[0];
|
|
174
|
+
// The callback's item is whatever the array it walks holds.
|
|
175
|
+
const listOrigin = again(node.callee.object.callee.object);
|
|
176
|
+
const item = fn && fn.params && fn.params[0] && fn.params[0].type === 'Identifier' ? fn.params[0].name : null;
|
|
177
|
+
const inner = Object.assign({}, aliases || {});
|
|
178
|
+
if (item) inner[item] = listOrigin;
|
|
179
|
+
return classify(returnedBy(fn), scope, code, depth + 1, inner);
|
|
180
|
+
}
|
|
181
|
+
// A function of this file's own: as dangerous as what it returns. One
|
|
182
|
+
// that only ever returns constants - a lookup of labels, icons and
|
|
183
|
+
// colours - cannot carry anything in; one that returns a parameter
|
|
184
|
+
// stays unknown, because its parameters are.
|
|
185
|
+
const local = node.callee.type === 'Identifier' ? localFunction(node.callee.name, scope) : null;
|
|
186
|
+
if (local) {
|
|
187
|
+
const returns = returnsOf(local);
|
|
188
|
+
if (returns.length) return worst(returns.map((r) => classify(r.node, r.scope, code, depth + 1, aliases)));
|
|
189
|
+
}
|
|
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) &&
|
|
192
|
+
node.callee.type === 'MemberExpression') {
|
|
193
|
+
return again(node.callee.object);
|
|
194
|
+
}
|
|
195
|
+
return 'unknown';
|
|
196
|
+
}
|
|
197
|
+
case 'ObjectExpression':
|
|
198
|
+
// Only as dangerous as what is in it. A spread brings in whatever it spreads.
|
|
199
|
+
return worst(node.properties.map((p) => (p.type === 'SpreadElement' ? again(p.argument) : p.value && again(p.value))));
|
|
200
|
+
case 'ArrayExpression':
|
|
201
|
+
return worst(node.elements.map((e) => (e ? again(e.type === 'SpreadElement' ? e.argument : e) : 'safe')));
|
|
202
|
+
case 'MemberExpression':
|
|
203
|
+
case 'OptionalMemberExpression': {
|
|
204
|
+
const prop = node.property && !node.computed ? node.property.name : '';
|
|
205
|
+
// The page's own markup, read back: button.innerHTML saved and put back
|
|
206
|
+
// adds nothing that was not already there. textContent is not this - it
|
|
207
|
+
// is text, and a "<" in it becomes a tag when written as HTML.
|
|
208
|
+
if (prop === 'innerHTML' || prop === 'outerHTML') return 'safe';
|
|
209
|
+
if (NUMERIC_PROPS.test(prop || '')) return 'safe';
|
|
210
|
+
if (prop === 'message' && isCatchParam(node.object, scope)) return 'error';
|
|
211
|
+
// labels().title - a property of what a call returns is judged by the
|
|
212
|
+
// call; (await res.json()).name by the body it was read from.
|
|
213
|
+
let base = node.object;
|
|
214
|
+
while (base && /MemberExpression$/.test(base.type)) base = base.object;
|
|
215
|
+
if (base && (/CallExpression$/.test(base.type) || base.type === 'AwaitExpression')) return again(base);
|
|
216
|
+
return fromBinding(rootIdentifier(node), scope, code, depth, aliases);
|
|
217
|
+
}
|
|
218
|
+
case 'Identifier':
|
|
219
|
+
return fromBinding(node, scope, code, depth, aliases);
|
|
220
|
+
case 'AwaitExpression':
|
|
221
|
+
return again(node.argument);
|
|
222
|
+
default:
|
|
223
|
+
return 'unknown';
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const RANK = { safe: 0, unknown: 1, error: 2, network: 3 };
|
|
228
|
+
function worst(list) {
|
|
229
|
+
return (list || []).reduce((a, b) => (RANK[b] > RANK[a] ? b : a), 'safe');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function returnedBy(fn) {
|
|
233
|
+
if (!fn || !/Function/.test(fn.type)) return null;
|
|
234
|
+
if (fn.body.type !== 'BlockStatement') return fn.body;
|
|
235
|
+
const ret = fn.body.body.find((s) => s.type === 'ReturnStatement');
|
|
236
|
+
return ret ? ret.argument : null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The path of a function this file declares under `name`, or null. */
|
|
240
|
+
function localFunction(name, scope) {
|
|
241
|
+
const binding = scope && scope.getBinding(name);
|
|
242
|
+
if (!binding || !binding.path) return null;
|
|
243
|
+
const p = binding.path;
|
|
244
|
+
if (p.node.type === 'FunctionDeclaration') return p;
|
|
245
|
+
if (p.node.type === 'VariableDeclarator' && p.node.init && /Function/.test(p.node.init.type) &&
|
|
246
|
+
!(binding.constantViolations && binding.constantViolations.length)) {
|
|
247
|
+
return p.get('init');
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Every value a function returns, with the scope to judge it in; nested functions excluded. */
|
|
253
|
+
function returnsOf(fnPath) {
|
|
254
|
+
if (fnPath.node.body.type !== 'BlockStatement') return [{ node: fnPath.node.body, scope: fnPath.scope }];
|
|
255
|
+
const found = [];
|
|
256
|
+
fnPath.traverse({
|
|
257
|
+
Function(p) {
|
|
258
|
+
p.skip();
|
|
259
|
+
},
|
|
260
|
+
ReturnStatement(p) {
|
|
261
|
+
if (p.node.argument) found.push({ node: p.node.argument, scope: p.scope });
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
return found;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function rootIdentifier(node) {
|
|
268
|
+
let n = node;
|
|
269
|
+
while (n && /MemberExpression$/.test(n.type)) n = n.object;
|
|
270
|
+
return n && n.type === 'Identifier' ? n : null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function isCatchParam(node, scope) {
|
|
274
|
+
const id = rootIdentifier(node) || (node && node.type === 'Identifier' ? node : null);
|
|
275
|
+
if (!id || !scope) return false;
|
|
276
|
+
const binding = scope.getBinding(id.name);
|
|
277
|
+
return !!(binding && binding.path && binding.path.type === 'CatchClause');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** What a name was set to, followed one step back when that says anything. */
|
|
281
|
+
function fromBinding(id, scope, code, depth, aliases) {
|
|
282
|
+
if (id && aliases && Object.prototype.hasOwnProperty.call(aliases, id.name)) return aliases[id.name];
|
|
283
|
+
if (!id || !scope) return 'unknown';
|
|
284
|
+
const binding = scope.getBinding(id.name);
|
|
285
|
+
if (!binding) return 'unknown';
|
|
286
|
+
if (binding.path && binding.path.type === 'CatchClause') return 'error';
|
|
287
|
+
const init = binding.path && binding.path.node && binding.path.node.init;
|
|
288
|
+
if (!init) return 'unknown';
|
|
289
|
+
if (readsResponseBody(init)) return 'network';
|
|
290
|
+
// Reassigned later: the first value says nothing reliable about the last.
|
|
291
|
+
if (binding.constantViolations && binding.constantViolations.length) return 'unknown';
|
|
292
|
+
// Destructured from a response body: const { name } = await res.json()
|
|
293
|
+
return classify(init, binding.path.scope, code, depth + 1);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function readsResponseBody(node) {
|
|
297
|
+
let n = node;
|
|
298
|
+
if (n && n.type === 'AwaitExpression') n = n.argument;
|
|
299
|
+
if (!n || !/CallExpression$/.test(n.type)) return false;
|
|
300
|
+
const name = calleeName(n.callee);
|
|
301
|
+
if (name === 'json' || name === 'text') return true;
|
|
302
|
+
// res.json().catch(() => ({})) - the body read, with a fallback for when it
|
|
303
|
+
// is not JSON. Still the server's words whenever it is.
|
|
304
|
+
if ((name === 'catch' || name === 'finally') && n.callee.type === 'MemberExpression') {
|
|
305
|
+
return readsResponseBody(n.callee.object);
|
|
306
|
+
}
|
|
307
|
+
// (await fetch(url)).json() and fetch(url).then(r => r.json())
|
|
308
|
+
if (name === 'then') return (n.arguments || []).some((fn) => {
|
|
309
|
+
const body = returnedBy(fn);
|
|
310
|
+
return body && readsResponseBody(body);
|
|
311
|
+
});
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* When the value is a parameter of the function it sits in - a helper such as
|
|
317
|
+
* setStatus(html) { box.innerHTML = html } - which function, and which place
|
|
318
|
+
* in its argument list. Null for anything else.
|
|
319
|
+
*/
|
|
320
|
+
function paramOf(value, scope) {
|
|
321
|
+
if (!value || value.type !== 'Identifier' || !scope) return null;
|
|
322
|
+
const binding = scope.getBinding(value.name);
|
|
323
|
+
if (!binding || binding.kind !== 'param' || !binding.path) return null;
|
|
324
|
+
const fn = binding.path.parentPath;
|
|
325
|
+
if (!fn || !/Function/.test(fn.node.type)) return null;
|
|
326
|
+
const index = fn.node.params.indexOf(binding.path.node);
|
|
327
|
+
if (index === -1) return null;
|
|
328
|
+
let name = fn.node.id && fn.node.id.name;
|
|
329
|
+
if (!name && fn.parentPath && fn.parentPath.node.type === 'VariableDeclarator' && fn.parentPath.node.id.type === 'Identifier') {
|
|
330
|
+
name = fn.parentPath.node.id.name;
|
|
331
|
+
}
|
|
332
|
+
return name ? { fn: name, index: index } : null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Everything in one piece of code that turns a non-literal value into HTML. */
|
|
336
|
+
function sinksIn(parser, code, file, lineOffset) {
|
|
337
|
+
const found = [];
|
|
338
|
+
const ast = parser.babelParse(code, file, true);
|
|
339
|
+
const lineOf = (node) => (node.loc ? node.loc.start.line : 1) + lineOffset - 1;
|
|
340
|
+
const textOf = (node) => code.slice(node.start, node.end).replace(/\s+/g, ' ').slice(0, 160);
|
|
341
|
+
// Calls to plain named functions, so a helper's sink can be judged by what
|
|
342
|
+
// each call actually hands it.
|
|
343
|
+
const calls = new Map();
|
|
344
|
+
const viaHelper = [];
|
|
345
|
+
const add = (node, scope, sink, value) => {
|
|
346
|
+
const param = paramOf(value, scope);
|
|
347
|
+
if (param) {
|
|
348
|
+
viaHelper.push({ node: node, sink: sink, value: value, scope: scope, param: param });
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const origin = classify(value, scope, code, 0);
|
|
352
|
+
if (origin === 'safe') return;
|
|
353
|
+
found.push({ line: lineOf(node), sink: sink, expression: textOf(value), origin: origin });
|
|
354
|
+
};
|
|
355
|
+
parser.traverse(ast, {
|
|
356
|
+
CallExpression: {
|
|
357
|
+
exit(p) {
|
|
358
|
+
if (p.node.callee.type !== 'Identifier') return;
|
|
359
|
+
const list = calls.get(p.node.callee.name) || [];
|
|
360
|
+
list.push({ node: p.node, scope: p.scope });
|
|
361
|
+
calls.set(p.node.callee.name, list);
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
parser.traverse(ast, {
|
|
366
|
+
AssignmentExpression(p) {
|
|
367
|
+
const left = p.node.left;
|
|
368
|
+
if (left.type !== 'MemberExpression' || left.computed) return;
|
|
369
|
+
const prop = left.property && left.property.name;
|
|
370
|
+
if (prop === 'innerHTML' || prop === 'outerHTML') add(p.node, p.scope, prop, p.node.right);
|
|
371
|
+
},
|
|
372
|
+
CallExpression(p) {
|
|
373
|
+
const callee = p.node.callee;
|
|
374
|
+
const name = calleeName(callee);
|
|
375
|
+
const args = p.node.arguments;
|
|
376
|
+
if (name === 'insertAdjacentHTML' && args[1]) add(p.node, p.scope, 'insertAdjacentHTML', args[1]);
|
|
377
|
+
else if ((name === 'write' || name === 'writeln') && callee.type === 'MemberExpression' &&
|
|
378
|
+
callee.object && callee.object.name === 'document' && args[0]) {
|
|
379
|
+
add(p.node, p.scope, 'document.' + name, args[0]);
|
|
380
|
+
} else if (name === 'html' && callee.type === 'MemberExpression' && args.length === 1) {
|
|
381
|
+
add(p.node, p.scope, '.html()', args[0]);
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
JSXAttribute(p) {
|
|
385
|
+
if (!p.node.name || p.node.name.name !== 'dangerouslySetInnerHTML') return;
|
|
386
|
+
const expr = p.node.value && p.node.value.expression;
|
|
387
|
+
if (!expr || expr.type !== 'ObjectExpression') return;
|
|
388
|
+
const html = expr.properties.find((prop) => prop.key && (prop.key.name === '__html' || prop.key.value === '__html'));
|
|
389
|
+
if (html) add(p.node, p.scope, 'dangerouslySetInnerHTML', html.value);
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// A helper's sink is judged at its calls. Most calls of a helper like this
|
|
394
|
+
// pass HTML on purpose, so the risk is the one call that passes something
|
|
395
|
+
// from outside - and that is where it has to be fixed. A helper with no call
|
|
396
|
+
// this file can see cannot be judged that way, so its sink is reported where
|
|
397
|
+
// it is, as unknown, rather than dropped.
|
|
398
|
+
for (const h of viaHelper) {
|
|
399
|
+
const seen = calls.get(h.param.fn) || [];
|
|
400
|
+
if (!seen.length) {
|
|
401
|
+
found.push({ line: lineOf(h.node), sink: h.sink, expression: textOf(h.value), origin: 'unknown' });
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
for (const call of seen) {
|
|
405
|
+
const arg = call.node.arguments[h.param.index];
|
|
406
|
+
if (!arg) continue;
|
|
407
|
+
const origin = classify(arg, call.scope, code, 0);
|
|
408
|
+
if (origin === 'safe') continue;
|
|
409
|
+
found.push({
|
|
410
|
+
line: lineOf(call.node),
|
|
411
|
+
sink: h.sink,
|
|
412
|
+
expression: textOf(arg),
|
|
413
|
+
origin: origin,
|
|
414
|
+
via: { fn: h.param.fn, line: lineOf(h.node) },
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return found;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Reads the project at `root`. Returns
|
|
423
|
+
* { ran, why?, filesRead, findings: [...], unreadable: [{ file, why }] }
|
|
424
|
+
* `ran: false` means the check did not happen - never that it passed.
|
|
425
|
+
*/
|
|
426
|
+
function scanProject(root, options) {
|
|
427
|
+
const packageDir = (options && options.packageDir) || __dirname;
|
|
428
|
+
const parser = (options && options.parser) || loadParser(packageDir);
|
|
429
|
+
if (!parser) {
|
|
430
|
+
return {
|
|
431
|
+
ran: false,
|
|
432
|
+
why: 'the JavaScript parser this check uses was not found in this install of Playwright',
|
|
433
|
+
filesRead: 0,
|
|
434
|
+
findings: [],
|
|
435
|
+
unreadable: [],
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
const findings = [];
|
|
439
|
+
const unreadable = [];
|
|
440
|
+
let filesRead = 0;
|
|
441
|
+
for (const full of listFiles(root)) {
|
|
442
|
+
let size = 0;
|
|
443
|
+
try {
|
|
444
|
+
size = fs.statSync(full).size;
|
|
445
|
+
} catch (err) {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
const rel = path.relative(root, full).split(path.sep).join('/');
|
|
449
|
+
if (size > MAX_BYTES) {
|
|
450
|
+
unreadable.push({ file: rel, why: 'larger than ' + Math.round(MAX_BYTES / 1024) + ' KB, probably generated' });
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
const source = fs.readFileSync(full, 'utf8');
|
|
454
|
+
filesRead++;
|
|
455
|
+
const pieces = PAGE_EXT.test(full) ? scriptsIn(source) : [{ code: source, line: 1 }];
|
|
456
|
+
for (const piece of pieces) {
|
|
457
|
+
try {
|
|
458
|
+
for (const f of sinksIn(parser, piece.code, full, piece.line)) findings.push(Object.assign({ file: rel }, f));
|
|
459
|
+
} catch (err) {
|
|
460
|
+
unreadable.push({ file: rel + (piece.line > 1 ? ':' + piece.line : ''), why: 'could not be parsed: ' + String(err.message).split('\n')[0].slice(0, 120) });
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (/\.(vue|html)$/i.test(full)) {
|
|
464
|
+
for (const v of vHtmlIn(source)) {
|
|
465
|
+
findings.push({ file: rel, line: v.line, sink: 'v-html', expression: v.expression, origin: 'unknown' });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return { ran: true, filesRead: filesRead, findings: findings.map(describe), unreadable: unreadable };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const ORIGIN_WORDS = {
|
|
473
|
+
network: 'read from a server response',
|
|
474
|
+
error: "an error's message, which often carries text a server sent",
|
|
475
|
+
unknown: 'from somewhere the code alone cannot pin down',
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
// Wrapped here rather than by whoever prints it: this is pasted into a chat
|
|
479
|
+
// box as often as it is read in a terminal, and a long line is a wall in both.
|
|
480
|
+
// A quoted piece of code is never wrapped - breaking it would change it.
|
|
481
|
+
function wrap(text, width) {
|
|
482
|
+
const out = [];
|
|
483
|
+
let current = '';
|
|
484
|
+
for (const word of String(text).split(/\s+/)) {
|
|
485
|
+
if (current && (current + ' ' + word).length > width) {
|
|
486
|
+
out.push(current);
|
|
487
|
+
current = word;
|
|
488
|
+
} else {
|
|
489
|
+
current = current ? current + ' ' + word : word;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (current) out.push(current);
|
|
493
|
+
return out.join('\n');
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** One finding, in the shape every Kryptheon report uses. */
|
|
497
|
+
function describe(f) {
|
|
498
|
+
const severity = f.origin === 'unknown' ? 'MEDIUM' : 'HIGH';
|
|
499
|
+
const how = f.sink === 'v-html' ? 'v-html' : f.sink;
|
|
500
|
+
const where = f.via
|
|
501
|
+
? 'In ' + f.file + ' at line ' + f.line + ', this value is passed to ' + f.via.fn + '(), which puts ' +
|
|
502
|
+
'what it is given into the page as HTML with ' + how + ' at line ' + f.via.line + ':'
|
|
503
|
+
: 'In ' + f.file + ' at line ' + f.line + ', this value is put into the page as HTML with ' + how + ':';
|
|
504
|
+
// The fix for a helper is different, and getting it wrong breaks the page:
|
|
505
|
+
// switching the helper to textContent makes every other call - the ones
|
|
506
|
+
// that pass HTML on purpose - show their tags as text. Found by applying the
|
|
507
|
+
// plain version of this prompt to a real helper like this.
|
|
508
|
+
const fix = f.via
|
|
509
|
+
? [
|
|
510
|
+
'Do not change ' + f.via.fn + '() to use textContent: other calls pass HTML to it on purpose, ' +
|
|
511
|
+
'and they would show their tags as text. Fix it here instead - escape the dynamic part of ' +
|
|
512
|
+
'this value before it is joined into the HTML (replace & < > " \' with their HTML entities), ' +
|
|
513
|
+
'or pass it through a sanitizer such as DOMPurify if it must keep some formatting.',
|
|
514
|
+
]
|
|
515
|
+
: [
|
|
516
|
+
'Fix it so the value is always shown as text: set textContent instead of innerHTML for the ' +
|
|
517
|
+
'dynamic part, or escape it before it is joined into HTML (replace & < > " \' with their ' +
|
|
518
|
+
'HTML entities). If the HTML is meant to allow some formatting, pass it through a sanitizer ' +
|
|
519
|
+
'such as DOMPurify first.',
|
|
520
|
+
];
|
|
521
|
+
const fixPrompt = [
|
|
522
|
+
'My app may have a security problem - please check it rather than assume it.',
|
|
523
|
+
'',
|
|
524
|
+
where,
|
|
525
|
+
'',
|
|
526
|
+
' ' + f.expression,
|
|
527
|
+
'',
|
|
528
|
+
'The value is ' + ORIGIN_WORDS[f.origin] + '. If it can ever contain text a user or another ' +
|
|
529
|
+
'account typed, a "<" in it becomes a real tag and can run script in this page with the ' +
|
|
530
|
+
"signed-in person's session.",
|
|
531
|
+
'',
|
|
532
|
+
].concat(fix, [
|
|
533
|
+
'',
|
|
534
|
+
'Keep the page looking the same, change only how this value is inserted, and look for the ' +
|
|
535
|
+
'same pattern elsewhere in this file. Afterwards, open this page and check that ordinary ' +
|
|
536
|
+
'text, and text containing < and &, both display correctly.',
|
|
537
|
+
]).map((p) => (p.startsWith(' ') ? p : wrap(p, 72))).join('\n');
|
|
538
|
+
return {
|
|
539
|
+
kind: 'unsafe-html',
|
|
540
|
+
status: 'verification required',
|
|
541
|
+
evidence: 'code analysis',
|
|
542
|
+
severity: severity,
|
|
543
|
+
confidence: f.origin === 'unknown' ? 'low' : 'medium',
|
|
544
|
+
file: f.file,
|
|
545
|
+
line: f.line,
|
|
546
|
+
sink: f.sink,
|
|
547
|
+
expression: f.expression,
|
|
548
|
+
origin: f.origin,
|
|
549
|
+
via: f.via || null,
|
|
550
|
+
headline: 'Text ' + (f.origin === 'unknown' ? 'of unknown origin' : ORIGIN_WORDS[f.origin].split(',')[0]) +
|
|
551
|
+
' is inserted as HTML in ' + f.file + ':' + f.line +
|
|
552
|
+
(f.via ? ' (through ' + f.via.fn + '() at line ' + f.via.line + ')' : '') + '.',
|
|
553
|
+
fixPrompt: fixPrompt,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
module.exports = {
|
|
558
|
+
scanProject: scanProject,
|
|
559
|
+
listFiles: listFiles,
|
|
560
|
+
loadParser: loadParser,
|
|
561
|
+
scriptsIn: scriptsIn,
|
|
562
|
+
classify: classify,
|
|
563
|
+
describe: describe,
|
|
564
|
+
};
|