kryptheon 0.1.28 → 0.1.30
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 +16 -2
- package/kryptheon-baselines.js +56 -0
- package/kryptheon-code.js +338 -27
- package/kryptheon-reporter.js +1080 -1048
- package/package.json +3 -2
package/bin/kryptheon.js
CHANGED
|
@@ -2659,8 +2659,22 @@ function accept(name) {
|
|
|
2659
2659
|
|
|
2660
2660
|
console.error('');
|
|
2661
2661
|
if (result.reason === 'nothing-pending') {
|
|
2662
|
-
|
|
2663
|
-
|
|
2662
|
+
// Said from the last run of this test, not assumed: a run that broke on
|
|
2663
|
+
// a step is not one that matched (see baselines.nothingToAcceptLines).
|
|
2664
|
+
const last = baselines.lastRunOf(store.pathsFor(USER_DIR).history, key);
|
|
2665
|
+
baselines.nothingToAcceptLines(titleOf(key), last).forEach((paragraph, i) => {
|
|
2666
|
+
if (i) console.error('');
|
|
2667
|
+
let current = '';
|
|
2668
|
+
for (const word of paragraph.split(' ')) {
|
|
2669
|
+
if ((current + ' ' + word).trim().length > 72) {
|
|
2670
|
+
console.error(' ' + current.trim());
|
|
2671
|
+
current = word;
|
|
2672
|
+
} else {
|
|
2673
|
+
current = (current + ' ' + word).trim();
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
if (current) console.error(' ' + current);
|
|
2677
|
+
});
|
|
2664
2678
|
} else if (result.reason === 'no-entry') {
|
|
2665
2679
|
console.error(' "' + titleOf(key) + '" has no saved result yet.');
|
|
2666
2680
|
} else {
|
package/kryptheon-baselines.js
CHANGED
|
@@ -166,6 +166,60 @@ function pruneLines(dropped) {
|
|
|
166
166
|
return lines;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
/**
|
|
170
|
+
* The last run of one recorded test, from the run history: whether it
|
|
171
|
+
* passed, and where it broke if it did not. Null when it has never run.
|
|
172
|
+
*/
|
|
173
|
+
function lastRunOf(historyFile, key) {
|
|
174
|
+
let lines = [];
|
|
175
|
+
try {
|
|
176
|
+
lines = fs.readFileSync(historyFile, 'utf8').split('\n').filter(Boolean);
|
|
177
|
+
} catch (e) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
const spec = specOf(key);
|
|
181
|
+
const title = titleOf(key);
|
|
182
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
183
|
+
let entry;
|
|
184
|
+
try {
|
|
185
|
+
entry = JSON.parse(lines[i]);
|
|
186
|
+
} catch (e) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const test = (entry.tests || []).find((t) => t && t.title === title && specOf(t.specFile) === spec);
|
|
190
|
+
if (test) return { status: test.status, at: entry.runAt, failure: test.failure || null };
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* What accept says when there is nothing it can take, from the last run.
|
|
197
|
+
*
|
|
198
|
+
* Found on a blind test (HelixOps): a step was broken on purpose, and accept
|
|
199
|
+
* answered "it last matched its saved result" - the opposite of what had
|
|
200
|
+
* happened. accept only takes a run that went all the way through and ended
|
|
201
|
+
* on a different page; a step that no longer matches is a changed flow or a
|
|
202
|
+
* broken app, and neither is accepted.
|
|
203
|
+
*/
|
|
204
|
+
function nothingToAcceptLines(name, last) {
|
|
205
|
+
if (last && last.status === 'failed') {
|
|
206
|
+
const failure = last.failure || {};
|
|
207
|
+
const where = failure.line ? ' at line ' + failure.line + (failure.file ? ' of ' + specOf(failure.file) : '') : '';
|
|
208
|
+
const what = failure.plainLanguage ? ' (' + String(failure.plainLanguage).replace(/\.$/, '') + ')' : '';
|
|
209
|
+
return [
|
|
210
|
+
'"' + name + '" has nothing to accept: its last run broke' + where + ', before the end' + what + '.',
|
|
211
|
+
'accept only covers a run that went all the way through and finished on a different page - ' +
|
|
212
|
+
'a new address or a new title.',
|
|
213
|
+
'A step that no longer matches cannot be accepted. If the flow changed on purpose, record it ' +
|
|
214
|
+
'again (npx kryptheon record). If it did not, the app broke, and npx kryptheon check says what to fix.',
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
if (last && last.status === 'passed') {
|
|
218
|
+
return ['"' + name + '" has no new result waiting.', 'Nothing to accept - its last run matched its saved result.'];
|
|
219
|
+
}
|
|
220
|
+
return ['"' + name + '" has no new result waiting.', 'Nothing to accept - no run has finished on a different page.'];
|
|
221
|
+
}
|
|
222
|
+
|
|
169
223
|
module.exports = {
|
|
170
224
|
specOf: specOf,
|
|
171
225
|
titleOf: titleOf,
|
|
@@ -173,4 +227,6 @@ module.exports = {
|
|
|
173
227
|
classifyBaselines: classifyBaselines,
|
|
174
228
|
pruneBaselines: pruneBaselines,
|
|
175
229
|
pruneLines: pruneLines,
|
|
230
|
+
lastRunOf: lastRunOf,
|
|
231
|
+
nothingToAcceptLines: nothingToAcceptLines,
|
|
176
232
|
};
|
package/kryptheon-code.js
CHANGED
|
@@ -412,7 +412,7 @@ function riskyParts(node, scope, code, aliases, out, depth) {
|
|
|
412
412
|
}
|
|
413
413
|
}
|
|
414
414
|
const text = code.slice(node.start, node.end).replace(/\s+/g, ' ').slice(0, 80);
|
|
415
|
-
if (!out.some((p) => p.text === text)) out.push({ text: text, origin: origin });
|
|
415
|
+
if (!out.some((p) => p.text === text)) out.push({ text: text, origin: origin, line: node.loc ? node.loc.start.line : null });
|
|
416
416
|
}
|
|
417
417
|
|
|
418
418
|
function rootIdentifier(node) {
|
|
@@ -432,6 +432,50 @@ function isCatchParam(node, scope) {
|
|
|
432
432
|
// from itself (count = count + 1) is not followed round in a circle.
|
|
433
433
|
const RESOLVING = new Set();
|
|
434
434
|
|
|
435
|
+
// The project being read, for a value imported from another of its files:
|
|
436
|
+
// where each file's names lead, and which of them anything ever writes to.
|
|
437
|
+
// Set by scanProject for the length of one read, like FN_PATHS.
|
|
438
|
+
let IMPORTS = null;
|
|
439
|
+
// Which file each parsed program is, so an import is resolved from the file
|
|
440
|
+
// that wrote it - which, once a helper in another folder is being judged, is
|
|
441
|
+
// not the file the sink is in.
|
|
442
|
+
let PROGRAM_FILE = new WeakMap();
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Fixed data another file of the project exports - `export const demo = {...}`
|
|
446
|
+
* of nothing but literals - is as safe as those literals, and only while no
|
|
447
|
+
* file writes to it. Found on a blind test (LaunchRail): mock-data.js exported
|
|
448
|
+
* the demo workspace, ui.js rendered it through a shell() helper, and the
|
|
449
|
+
* import was read as unknown - so a safe page was reported.
|
|
450
|
+
*
|
|
451
|
+
* What is followed, and what is not:
|
|
452
|
+
* - a named import of a `const` declared at the top of a project file;
|
|
453
|
+
* - nothing at all if any file of the project could not be read, since a
|
|
454
|
+
* write could be in that file (IMPORTS.blind);
|
|
455
|
+
* - a write anywhere - through the import, any other file's import of it,
|
|
456
|
+
* a namespace import, a local name given to part of it, or the defining
|
|
457
|
+
* file itself - makes it unknown again (IMPORTS.written).
|
|
458
|
+
* Handing it out - `return demo.flags` - is a read, and is not followed into
|
|
459
|
+
* whoever receives it. That is the one place a write could still be missed: a
|
|
460
|
+
* caller that pushes into the very list it was returned. Writing into shared
|
|
461
|
+
* fixed data that way is rare enough to accept here, and is said so.
|
|
462
|
+
*/
|
|
463
|
+
function fromImport(binding, depth) {
|
|
464
|
+
if (!IMPORTS || IMPORTS.blind) return 'unknown';
|
|
465
|
+
const spec = binding.path && binding.path.node;
|
|
466
|
+
if (!spec || spec.type !== 'ImportSpecifier') return 'unknown';
|
|
467
|
+
const program = binding.scope && binding.scope.getProgramParent();
|
|
468
|
+
const here = program && PROGRAM_FILE.get(program.block);
|
|
469
|
+
if (!here) return 'unknown';
|
|
470
|
+
const decl = binding.path.parentPath && binding.path.parentPath.node;
|
|
471
|
+
const from = decl && decl.source && decl.source.value;
|
|
472
|
+
const name = spec.imported && (spec.imported.name || spec.imported.value);
|
|
473
|
+
const found = from && name ? IMPORTS.constOf(here, from, name) : null;
|
|
474
|
+
if (!found) return 'unknown';
|
|
475
|
+
if (IMPORTS.written.has(found.full + '::' + name) || IMPORTS.written.has(found.full + '::*')) return 'unknown';
|
|
476
|
+
return classify(found.init, found.scope, found.code, depth + 1);
|
|
477
|
+
}
|
|
478
|
+
|
|
435
479
|
/** What a name was set to, followed one step back when that says anything. */
|
|
436
480
|
function fromBinding(id, scope, code, depth, aliases) {
|
|
437
481
|
if (id && aliases && Object.prototype.hasOwnProperty.call(aliases, id.name)) return aliases[id.name];
|
|
@@ -440,6 +484,7 @@ function fromBinding(id, scope, code, depth, aliases) {
|
|
|
440
484
|
if (!binding) return 'unknown';
|
|
441
485
|
if (binding.path && binding.path.type === 'CatchClause') return 'error';
|
|
442
486
|
if (binding.kind === 'param') return fromCalls(binding, code, depth);
|
|
487
|
+
if (binding.kind === 'module') return fromImport(binding, depth);
|
|
443
488
|
const init = binding.path && binding.path.node && binding.path.node.init;
|
|
444
489
|
const later = binding.constantViolations || [];
|
|
445
490
|
if (!init && !later.length) return 'unknown';
|
|
@@ -684,9 +729,230 @@ function checkedFirst(p, value, code) {
|
|
|
684
729
|
return false;
|
|
685
730
|
}
|
|
686
731
|
|
|
687
|
-
|
|
732
|
+
/**
|
|
733
|
+
* The helper a value is the result of: a call to a function this file
|
|
734
|
+
* declares, or one it imports from a file of the project. Its returns, each
|
|
735
|
+
* with its scope and its file's code, and the line where the first of them
|
|
736
|
+
* that is not plain text is built. Null for anything else - an import from a
|
|
737
|
+
* package, a default export, a function that cannot be found.
|
|
738
|
+
*/
|
|
739
|
+
function helperOf(value, scope, code, file, lineOffset, resolver) {
|
|
740
|
+
if (!value || !/CallExpression$/.test(value.type) || !value.callee || value.callee.type !== 'Identifier' || !scope) return null;
|
|
741
|
+
const name = value.callee.name;
|
|
742
|
+
let fnPath = localFunction(name, scope);
|
|
743
|
+
let helper = { code: code, file: null, lineOffset: lineOffset };
|
|
744
|
+
if (!fnPath) {
|
|
745
|
+
const binding = scope.getBinding(name);
|
|
746
|
+
const spec = binding && binding.kind === 'module' && binding.path && binding.path.node;
|
|
747
|
+
if (!spec || spec.type !== 'ImportSpecifier' || !resolver) return null;
|
|
748
|
+
const from = binding.path.parentPath && binding.path.parentPath.node.source && binding.path.parentPath.node.source.value;
|
|
749
|
+
const imported = spec.imported && (spec.imported.name || spec.imported.value);
|
|
750
|
+
const found = from && imported ? resolver(file, from, imported) : null;
|
|
751
|
+
if (!found) return null;
|
|
752
|
+
fnPath = found.fnPath;
|
|
753
|
+
helper = { code: found.code, file: found.file, lineOffset: 1 };
|
|
754
|
+
}
|
|
755
|
+
const returns = returnsOf(fnPath);
|
|
756
|
+
if (!returns.length) return null;
|
|
757
|
+
const risky = returns.find((r) => classify(r.node, r.scope, helper.code, 0) !== 'safe') || returns[0];
|
|
758
|
+
return {
|
|
759
|
+
fn: name,
|
|
760
|
+
file: helper.file,
|
|
761
|
+
line: (risky.node.loc ? risky.node.loc.start.line : 1) + helper.lineOffset - 1,
|
|
762
|
+
code: helper.code,
|
|
763
|
+
lineOffset: helper.lineOffset,
|
|
764
|
+
returns: returns,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Finds a function another file of the project exports, for helperOf. Parsed
|
|
770
|
+
* once a file; its functions are handed to the map() reader each time, since
|
|
771
|
+
* that table is rebuilt for every file read.
|
|
772
|
+
*/
|
|
773
|
+
/** The project file a relative import names, or null for a package or nothing. */
|
|
774
|
+
function resolveModule(fromFile, source) {
|
|
775
|
+
if (!/^\.\.?\//.test(source)) return null;
|
|
776
|
+
const EXTS = ['', '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '/index.js', '/index.ts'];
|
|
777
|
+
const base = path.resolve(path.dirname(fromFile), source);
|
|
778
|
+
return EXTS.map((ext) => base + ext).find((candidate) => {
|
|
779
|
+
try {
|
|
780
|
+
return fs.statSync(candidate).isFile() && CODE_EXT.test(candidate);
|
|
781
|
+
} catch (err) {
|
|
782
|
+
return false;
|
|
783
|
+
}
|
|
784
|
+
}) || null;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function importResolver(parser, root) {
|
|
788
|
+
const parsed = new Map();
|
|
789
|
+
const entryOf = (fromFile, source) => {
|
|
790
|
+
const full = resolveModule(fromFile, source);
|
|
791
|
+
if (!full) return null;
|
|
792
|
+
if (!parsed.has(full)) {
|
|
793
|
+
let entry = null;
|
|
794
|
+
try {
|
|
795
|
+
if (fs.statSync(full).size <= MAX_BYTES) {
|
|
796
|
+
const code = fs.readFileSync(full, 'utf8');
|
|
797
|
+
const ast = parser.babelParse(code, full, true);
|
|
798
|
+
const fns = [];
|
|
799
|
+
let program = null;
|
|
800
|
+
parser.traverse(ast, {
|
|
801
|
+
Program(p) { program = p.scope; },
|
|
802
|
+
Function(p) { fns.push(p); },
|
|
803
|
+
});
|
|
804
|
+
if (program) PROGRAM_FILE.set(program.block, full);
|
|
805
|
+
entry = { code: code, program: program, fns: fns, file: path.relative(root, full).split(path.sep).join('/') };
|
|
806
|
+
}
|
|
807
|
+
} catch (err) {
|
|
808
|
+
entry = null;
|
|
809
|
+
}
|
|
810
|
+
parsed.set(full, entry);
|
|
811
|
+
}
|
|
812
|
+
const entry = parsed.get(full);
|
|
813
|
+
if (!entry || !entry.program) return null;
|
|
814
|
+
for (const p of entry.fns) FN_PATHS.set(p.node, p);
|
|
815
|
+
return Object.assign({ full: full }, entry);
|
|
816
|
+
};
|
|
817
|
+
const resolve = (fromFile, source, name) => {
|
|
818
|
+
const entry = entryOf(fromFile, source);
|
|
819
|
+
if (!entry) return null;
|
|
820
|
+
const fnPath = localFunction(name, entry.program);
|
|
821
|
+
return fnPath ? { fnPath: fnPath, code: entry.code, file: entry.file } : null;
|
|
822
|
+
};
|
|
823
|
+
// A `const` the file declares at its top, with the value it is given there.
|
|
824
|
+
resolve.constOf = (fromFile, source, name) => {
|
|
825
|
+
const entry = entryOf(fromFile, source);
|
|
826
|
+
if (!entry) return null;
|
|
827
|
+
const binding = entry.program.getBinding(name);
|
|
828
|
+
const node = binding && binding.path && binding.path.node;
|
|
829
|
+
if (!binding || binding.kind !== 'const' || !node || node.type !== 'VariableDeclarator' || !node.init) return null;
|
|
830
|
+
if (binding.constantViolations && binding.constantViolations.length) return null;
|
|
831
|
+
return { full: entry.full, init: node.init, scope: binding.path.scope, code: entry.code };
|
|
832
|
+
};
|
|
833
|
+
return resolve;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Methods that change the array, map or set they are called on.
|
|
837
|
+
const MUTATORS = /^(push|pop|shift|unshift|splice|sort|reverse|fill|copyWithin|set|add|delete|clear)$/;
|
|
838
|
+
// Calls that only read what they are given.
|
|
839
|
+
const READS_ONLY = /^(String|Number|Boolean|JSON\.stringify|console\.\w+|Array\.isArray|Object\.(keys|values|entries|freeze|isFrozen))$/;
|
|
840
|
+
// Methods that only read their argument: list.concat(demo.changes) builds a
|
|
841
|
+
// new list and leaves demo alone. push() and the like are not here - they put
|
|
842
|
+
// the value somewhere it can be written through.
|
|
843
|
+
const READS_ARGUMENT = /^(concat|includes|indexOf|lastIndexOf|startsWith|endsWith|localeCompare|has|at)$/;
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Whether this use of a name can change what the name holds: an assignment or
|
|
847
|
+
* delete through it, a mutating method on it, or handing it to a call that
|
|
848
|
+
* could change it. A local name given to part of it - `const list = demo.flags`
|
|
849
|
+
* - is followed, since writing to the list writes to demo.
|
|
850
|
+
*/
|
|
851
|
+
function writtenThrough(ref, depth) {
|
|
852
|
+
if ((depth || 0) > 4) return true;
|
|
853
|
+
let p = ref;
|
|
854
|
+
while (p.parentPath && /MemberExpression$/.test(p.parentPath.node.type) && p.parentPath.node.object === p.node) p = p.parentPath;
|
|
855
|
+
const parentPath = p.parentPath;
|
|
856
|
+
const parent = parentPath && parentPath.node;
|
|
857
|
+
if (!parent) return false;
|
|
858
|
+
if (parent.type === 'UpdateExpression') return true;
|
|
859
|
+
if (parent.type === 'UnaryExpression' && parent.operator === 'delete') return true;
|
|
860
|
+
// The target of an assignment, plain or inside a destructuring pattern.
|
|
861
|
+
let up = p;
|
|
862
|
+
while (up.parentPath && /^(ObjectProperty|ObjectPattern|ArrayPattern|AssignmentPattern|RestElement)$/.test(up.parentPath.node.type)) up = up.parentPath;
|
|
863
|
+
const holder = up.parentPath && up.parentPath.node;
|
|
864
|
+
if (holder && holder.type === 'AssignmentExpression' && holder.left === up.node) return true;
|
|
865
|
+
if (holder && /^For(Of|In)Statement$/.test(holder.type) && holder.left === up.node) return true;
|
|
866
|
+
if (/CallExpression$/.test(parent.type)) {
|
|
867
|
+
if (parent.callee === p.node) {
|
|
868
|
+
return /MemberExpression$/.test(p.node.type) && MUTATORS.test(calleeName(p.node));
|
|
869
|
+
}
|
|
870
|
+
if (parent.arguments.indexOf(p.node) !== -1) {
|
|
871
|
+
const callee = parent.callee;
|
|
872
|
+
const full = callee.type === 'MemberExpression' && callee.object.type === 'Identifier'
|
|
873
|
+
? callee.object.name + '.' + calleeName(callee) : calleeName(callee);
|
|
874
|
+
if (/MemberExpression$/.test(callee.type) && READS_ARGUMENT.test(calleeName(callee))) return false;
|
|
875
|
+
return !(MAKES_SAFE.test(calleeName(callee)) || READS_ONLY.test(full));
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
if (parent.type === 'VariableDeclarator' && parent.init === p.node && parent.id.type === 'Identifier') {
|
|
879
|
+
const alias = parentPath.scope.getBinding(parent.id.name);
|
|
880
|
+
if (!alias) return true;
|
|
881
|
+
if (alias.constantViolations && alias.constantViolations.length) return true;
|
|
882
|
+
return (alias.referencePaths || []).some((r) => writtenThrough(r, (depth || 0) + 1));
|
|
883
|
+
}
|
|
884
|
+
return false;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Every project name something writes to, as "<file>::<name>", read once over
|
|
889
|
+
* the whole project before any file is judged. `blind` when a file could not
|
|
890
|
+
* be read: a write could be in it, so no import is trusted at all.
|
|
891
|
+
*/
|
|
892
|
+
function projectWrites(parser, files) {
|
|
893
|
+
const written = new Set();
|
|
894
|
+
let blind = false;
|
|
895
|
+
for (const full of files) {
|
|
896
|
+
let pieces;
|
|
897
|
+
try {
|
|
898
|
+
if (fs.statSync(full).size > MAX_BYTES) continue;
|
|
899
|
+
const source = fs.readFileSync(full, 'utf8');
|
|
900
|
+
pieces = PAGE_EXT.test(full) ? scriptsIn(source) : [{ code: source }];
|
|
901
|
+
} catch (err) {
|
|
902
|
+
blind = true;
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
for (const piece of pieces) {
|
|
906
|
+
let ast;
|
|
907
|
+
try {
|
|
908
|
+
ast = parser.babelParse(piece.code, full, true);
|
|
909
|
+
} catch (err) {
|
|
910
|
+
blind = true;
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
parser.traverse(ast, {
|
|
914
|
+
Program(p) {
|
|
915
|
+
// This file's own top-level names, written to here.
|
|
916
|
+
for (const name of Object.keys(p.scope.bindings)) {
|
|
917
|
+
const b = p.scope.bindings[name];
|
|
918
|
+
if (b.kind === 'module') continue;
|
|
919
|
+
if ((b.constantViolations && b.constantViolations.length) ||
|
|
920
|
+
(b.referencePaths || []).some((r) => writtenThrough(r))) {
|
|
921
|
+
written.add(full + '::' + name);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
},
|
|
925
|
+
ImportDeclaration(p) {
|
|
926
|
+
const from = resolveModule(full, p.node.source.value);
|
|
927
|
+
if (!from) return;
|
|
928
|
+
for (const spec of p.node.specifiers) {
|
|
929
|
+
const b = p.scope.getBinding(spec.local.name);
|
|
930
|
+
if (!b) continue;
|
|
931
|
+
if (spec.type === 'ImportNamespaceSpecifier') {
|
|
932
|
+
for (const r of b.referencePaths || []) {
|
|
933
|
+
const m = r.parentPath && r.parentPath.node;
|
|
934
|
+
if (m && /MemberExpression$/.test(m.type) && m.object === r.node && !m.computed && m.property.type === 'Identifier') {
|
|
935
|
+
if (writtenThrough(r.parentPath)) written.add(from + '::' + m.property.name);
|
|
936
|
+
} else {
|
|
937
|
+
written.add(from + '::*');
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
const name = spec.type === 'ImportDefaultSpecifier' ? 'default' : (spec.imported.name || spec.imported.value);
|
|
943
|
+
if ((b.referencePaths || []).some((r) => writtenThrough(r))) written.add(from + '::' + name);
|
|
944
|
+
}
|
|
945
|
+
},
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
return { written: written, blind: blind };
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function sinksIn(parser, code, file, lineOffset, resolver) {
|
|
688
953
|
const found = [];
|
|
689
954
|
const ast = parser.babelParse(code, file, true);
|
|
955
|
+
PROGRAM_FILE.set(ast.program, file);
|
|
690
956
|
const lineOf = (node) => (node.loc ? node.loc.start.line : 1) + lineOffset - 1;
|
|
691
957
|
const textOf = (node) => code.slice(node.start, node.end).replace(/\s+/g, ' ').slice(0, 160);
|
|
692
958
|
// Calls to plain named functions, so a helper's sink can be judged by what
|
|
@@ -704,6 +970,25 @@ function sinksIn(parser, code, file, lineOffset) {
|
|
|
704
970
|
viaHelper.push({ node: node, sink: sink, value: value, scope: scope, param: param });
|
|
705
971
|
return;
|
|
706
972
|
}
|
|
973
|
+
// The HTML built by a helper - root.innerHTML = renderBoard(rows) - is
|
|
974
|
+
// judged by what the helper returns, in its own file, and the place it
|
|
975
|
+
// puts a value in is named. Found on a blind test (HelixOps): appShell(),
|
|
976
|
+
// a fixed string in ui.js, was reported as unknown, and renderBoard() was
|
|
977
|
+
// reported at its call with no word of ui.js:13, where the title goes in.
|
|
978
|
+
const built = helperOf(value, scope, code, file, lineOffset, resolver);
|
|
979
|
+
if (built) {
|
|
980
|
+
const origin = worst(built.returns.map((r) => classify(r.node, r.scope, built.code, 0)));
|
|
981
|
+
if (origin === 'safe') return;
|
|
982
|
+
const parts = [];
|
|
983
|
+
for (const r of built.returns) riskyParts(r.node, r.scope, built.code, undefined, parts, 0);
|
|
984
|
+
// The line of the first value that goes in, not of the return around it.
|
|
985
|
+
const first = parts.find((p) => p.line);
|
|
986
|
+
found.push({
|
|
987
|
+
line: lineOf(node), sink: sink, expression: textOf(value), origin: origin, parts: parts,
|
|
988
|
+
helper: { fn: built.fn, file: built.file, line: first ? first.line + built.lineOffset - 1 : built.line },
|
|
989
|
+
});
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
707
992
|
const origin = classify(value, scope, code, 0);
|
|
708
993
|
if (origin === 'safe') return;
|
|
709
994
|
found.push({ line: lineOf(node), sink: sink, expression: textOf(value), origin: origin, parts: partsOf(value, scope) });
|
|
@@ -834,36 +1119,49 @@ function scanProject(root, options) {
|
|
|
834
1119
|
}
|
|
835
1120
|
}
|
|
836
1121
|
const loadedBy = keys.scriptsLoadedBy(pages);
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
const pieces = PAGE_EXT.test(full) ? scriptsIn(source) : [{ code: source, line: 1 }];
|
|
853
|
-
for (const piece of pieces) {
|
|
1122
|
+
const resolver = importResolver(parser, root);
|
|
1123
|
+
// Before any file is judged: which of the project's own names anything
|
|
1124
|
+
// writes to, so fixed data is only trusted while it really is fixed.
|
|
1125
|
+
PROGRAM_FILE = new WeakMap();
|
|
1126
|
+
IMPORTS = Object.assign({ constOf: resolver.constOf }, projectWrites(parser, files));
|
|
1127
|
+
try {
|
|
1128
|
+
readAll();
|
|
1129
|
+
} finally {
|
|
1130
|
+
IMPORTS = null;
|
|
1131
|
+
}
|
|
1132
|
+
return { ran: true, filesRead: filesRead, findings: secrets.concat(findings.map(describe)), unreadable: unreadable };
|
|
1133
|
+
|
|
1134
|
+
function readAll() {
|
|
1135
|
+
for (const full of files) {
|
|
1136
|
+
let size = 0;
|
|
854
1137
|
try {
|
|
855
|
-
|
|
1138
|
+
size = fs.statSync(full).size;
|
|
856
1139
|
} catch (err) {
|
|
857
|
-
|
|
1140
|
+
continue;
|
|
858
1141
|
}
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
1142
|
+
const rel = path.relative(root, full).split(path.sep).join('/');
|
|
1143
|
+
if (size > MAX_BYTES) {
|
|
1144
|
+
unreadable.push({ file: rel, why: 'larger than ' + Math.round(MAX_BYTES / 1024) + ' KB, probably generated' });
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
const source = fs.readFileSync(full, 'utf8');
|
|
1148
|
+
filesRead++;
|
|
1149
|
+
for (const k of keys.secretsIn(rel, source, loadedBy)) secrets.push(k);
|
|
1150
|
+
const pieces = PAGE_EXT.test(full) ? scriptsIn(source) : [{ code: source, line: 1 }];
|
|
1151
|
+
for (const piece of pieces) {
|
|
1152
|
+
try {
|
|
1153
|
+
for (const f of sinksIn(parser, piece.code, full, piece.line, resolver)) findings.push(Object.assign({ file: rel }, f));
|
|
1154
|
+
} catch (err) {
|
|
1155
|
+
unreadable.push({ file: rel + (piece.line > 1 ? ':' + piece.line : ''), why: 'could not be parsed: ' + String(err.message).split('\n')[0].slice(0, 120) });
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
if (/\.(vue|html)$/i.test(full)) {
|
|
1159
|
+
for (const v of vHtmlIn(source)) {
|
|
1160
|
+
findings.push({ file: rel, line: v.line, sink: 'v-html', expression: v.expression, origin: 'unknown' });
|
|
1161
|
+
}
|
|
863
1162
|
}
|
|
864
1163
|
}
|
|
865
1164
|
}
|
|
866
|
-
return { ran: true, filesRead: filesRead, findings: secrets.concat(findings.map(describe)), unreadable: unreadable };
|
|
867
1165
|
}
|
|
868
1166
|
|
|
869
1167
|
const ORIGIN_WORDS = {
|
|
@@ -931,9 +1229,14 @@ function describe(f) {
|
|
|
931
1229
|
if (f.kind === 'redirect') return describeRedirect(f);
|
|
932
1230
|
const severity = f.origin === 'unknown' ? 'MEDIUM' : 'HIGH';
|
|
933
1231
|
const how = f.sink === 'v-html' ? 'v-html' : f.sink;
|
|
1232
|
+
// Where the helper builds it: its own file when it is another one.
|
|
1233
|
+
const builtAt = f.helper ? (f.helper.file ? f.helper.file + ':' : 'line ') + f.helper.line : '';
|
|
934
1234
|
const where = f.via
|
|
935
1235
|
? 'In ' + f.file + ' at line ' + f.line + ', this value is passed to ' + f.via.fn + '(), which puts ' +
|
|
936
1236
|
'what it is given into the page as HTML with ' + how + ' at line ' + f.via.line + ':'
|
|
1237
|
+
: f.helper
|
|
1238
|
+
? 'In ' + f.file + ' at line ' + f.line + ', the HTML that ' + f.helper.fn + '() builds - at ' + builtAt +
|
|
1239
|
+
' - is put into the page with ' + how + ':'
|
|
937
1240
|
: 'In ' + f.file + ' at line ' + f.line + ', this value is put into the page as HTML with ' + how + ':';
|
|
938
1241
|
// The fix for a helper is different, and getting it wrong breaks the page:
|
|
939
1242
|
// switching the helper to textContent makes every other call - the ones
|
|
@@ -946,6 +1249,12 @@ function describe(f) {
|
|
|
946
1249
|
'this value before it is joined into the HTML (replace & < > " \' with their HTML entities), ' +
|
|
947
1250
|
'or pass it through a sanitizer such as DOMPurify if it must keep some formatting.',
|
|
948
1251
|
]
|
|
1252
|
+
: f.helper
|
|
1253
|
+
? [
|
|
1254
|
+
'Fix it inside ' + f.helper.fn + '() at ' + builtAt + ', where the values are joined into the HTML: ' +
|
|
1255
|
+
'escape each dynamic value there (replace & < > " \' with their HTML entities), or pass the ' +
|
|
1256
|
+
'result through a sanitizer such as DOMPurify if it must keep some formatting.',
|
|
1257
|
+
]
|
|
949
1258
|
: [
|
|
950
1259
|
'Fix it so the value is always shown as text: set textContent instead of innerHTML for the ' +
|
|
951
1260
|
'dynamic part, or escape it before it is joined into HTML (replace & < > " \' with their ' +
|
|
@@ -993,9 +1302,11 @@ function describe(f) {
|
|
|
993
1302
|
origin: f.origin,
|
|
994
1303
|
parts: (f.parts || []).map((p) => ({ text: p.text, origin: p.origin })),
|
|
995
1304
|
via: f.via || null,
|
|
1305
|
+
helper: f.helper || null,
|
|
996
1306
|
headline: 'Text ' + (f.origin === 'unknown' ? 'of unknown origin' : ORIGIN_WORDS[f.origin].split(',')[0]) +
|
|
997
1307
|
' is inserted as HTML in ' + f.file + ':' + f.line +
|
|
998
|
-
(f.via ? ' (through ' + f.via.fn + '() at line ' + f.via.line + ')' : '') +
|
|
1308
|
+
(f.via ? ' (through ' + f.via.fn + '() at line ' + f.via.line + ')' : '') +
|
|
1309
|
+
(f.helper ? ', built by ' + f.helper.fn + '() at ' + builtAt : '') + '.',
|
|
999
1310
|
fixPrompt: fixPrompt,
|
|
1000
1311
|
};
|
|
1001
1312
|
}
|