kryptheon 0.1.29 → 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/kryptheon-code.js +231 -35
- package/kryptheon-reporter.js +1080 -1048
- package/package.json +3 -2
package/kryptheon-code.js
CHANGED
|
@@ -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';
|
|
@@ -725,19 +770,24 @@ function helperOf(value, scope, code, file, lineOffset, resolver) {
|
|
|
725
770
|
* once a file; its functions are handed to the map() reader each time, since
|
|
726
771
|
* that table is rebuilt for every file read.
|
|
727
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
|
+
|
|
728
787
|
function importResolver(parser, root) {
|
|
729
788
|
const parsed = new Map();
|
|
730
|
-
const
|
|
731
|
-
|
|
732
|
-
if (!/^\.\.?\//.test(source)) return null;
|
|
733
|
-
const base = path.resolve(path.dirname(fromFile), source);
|
|
734
|
-
const full = EXTS.map((ext) => base + ext).find((candidate) => {
|
|
735
|
-
try {
|
|
736
|
-
return fs.statSync(candidate).isFile() && CODE_EXT.test(candidate);
|
|
737
|
-
} catch (err) {
|
|
738
|
-
return false;
|
|
739
|
-
}
|
|
740
|
-
});
|
|
789
|
+
const entryOf = (fromFile, source) => {
|
|
790
|
+
const full = resolveModule(fromFile, source);
|
|
741
791
|
if (!full) return null;
|
|
742
792
|
if (!parsed.has(full)) {
|
|
743
793
|
let entry = null;
|
|
@@ -751,6 +801,7 @@ function importResolver(parser, root) {
|
|
|
751
801
|
Program(p) { program = p.scope; },
|
|
752
802
|
Function(p) { fns.push(p); },
|
|
753
803
|
});
|
|
804
|
+
if (program) PROGRAM_FILE.set(program.block, full);
|
|
754
805
|
entry = { code: code, program: program, fns: fns, file: path.relative(root, full).split(path.sep).join('/') };
|
|
755
806
|
}
|
|
756
807
|
} catch (err) {
|
|
@@ -761,14 +812,147 @@ function importResolver(parser, root) {
|
|
|
761
812
|
const entry = parsed.get(full);
|
|
762
813
|
if (!entry || !entry.program) return null;
|
|
763
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;
|
|
764
820
|
const fnPath = localFunction(name, entry.program);
|
|
765
821
|
return fnPath ? { fnPath: fnPath, code: entry.code, file: entry.file } : null;
|
|
766
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 };
|
|
767
950
|
}
|
|
768
951
|
|
|
769
952
|
function sinksIn(parser, code, file, lineOffset, resolver) {
|
|
770
953
|
const found = [];
|
|
771
954
|
const ast = parser.babelParse(code, file, true);
|
|
955
|
+
PROGRAM_FILE.set(ast.program, file);
|
|
772
956
|
const lineOf = (node) => (node.loc ? node.loc.start.line : 1) + lineOffset - 1;
|
|
773
957
|
const textOf = (node) => code.slice(node.start, node.end).replace(/\s+/g, ' ').slice(0, 160);
|
|
774
958
|
// Calls to plain named functions, so a helper's sink can be judged by what
|
|
@@ -936,36 +1120,48 @@ function scanProject(root, options) {
|
|
|
936
1120
|
}
|
|
937
1121
|
const loadedBy = keys.scriptsLoadedBy(pages);
|
|
938
1122
|
const resolver = importResolver(parser, root);
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
const
|
|
952
|
-
|
|
953
|
-
for (const k of keys.secretsIn(rel, source, loadedBy)) secrets.push(k);
|
|
954
|
-
const pieces = PAGE_EXT.test(full) ? scriptsIn(source) : [{ code: source, line: 1 }];
|
|
955
|
-
for (const piece of pieces) {
|
|
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;
|
|
956
1137
|
try {
|
|
957
|
-
|
|
1138
|
+
size = fs.statSync(full).size;
|
|
958
1139
|
} catch (err) {
|
|
959
|
-
|
|
1140
|
+
continue;
|
|
960
1141
|
}
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
+
}
|
|
965
1162
|
}
|
|
966
1163
|
}
|
|
967
1164
|
}
|
|
968
|
-
return { ran: true, filesRead: filesRead, findings: secrets.concat(findings.map(describe)), unreadable: unreadable };
|
|
969
1165
|
}
|
|
970
1166
|
|
|
971
1167
|
const ORIGIN_WORDS = {
|