kryptheon 0.1.29 → 0.1.31

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 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 EXTS = ['', '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '/index.js', '/index.ts'];
731
- return (fromFile, source, name) => {
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
- for (const full of files) {
940
- let size = 0;
941
- try {
942
- size = fs.statSync(full).size;
943
- } catch (err) {
944
- continue;
945
- }
946
- const rel = path.relative(root, full).split(path.sep).join('/');
947
- if (size > MAX_BYTES) {
948
- unreadable.push({ file: rel, why: 'larger than ' + Math.round(MAX_BYTES / 1024) + ' KB, probably generated' });
949
- continue;
950
- }
951
- const source = fs.readFileSync(full, 'utf8');
952
- filesRead++;
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
- for (const f of sinksIn(parser, piece.code, full, piece.line, resolver)) findings.push(Object.assign({ file: rel }, f));
1138
+ size = fs.statSync(full).size;
958
1139
  } catch (err) {
959
- unreadable.push({ file: rel + (piece.line > 1 ? ':' + piece.line : ''), why: 'could not be parsed: ' + String(err.message).split('\n')[0].slice(0, 120) });
1140
+ continue;
960
1141
  }
961
- }
962
- if (/\.(vue|html)$/i.test(full)) {
963
- for (const v of vHtmlIn(source)) {
964
- findings.push({ file: rel, line: v.line, sink: 'v-html', expression: v.expression, origin: 'unknown' });
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 = {
package/kryptheon-keys.js CHANGED
@@ -101,6 +101,23 @@ function looksLikeServiceRoleKey(value) {
101
101
  return (value.match(/[0-9]/g) || []).length >= 4;
102
102
  }
103
103
 
104
+ // A quoted value given to something named as a secret, in a format this does
105
+ // not know. Found on a blind test (HarborLine): api.js held
106
+ // `const replaySigningKey = 'hl_replay_8JQ4-...'` in code every visitor
107
+ // downloads, and the read named nothing - it knew only famous formats. The
108
+ // name says what it is meant to be; the value has to look like one too:
109
+ // long, letters and digits, no spaces, not a placeholder. Names that are
110
+ // public by design (anon, publishable, public) are left alone.
111
+ const NAMED_SECRET = /\b([A-Za-z_$][\w$]*)\s*[:=]\s*(["'`])([^"'`\s]{16,})\2/g;
112
+ const SECRET_NAME = /(secret|signingkey|privatekey|apikey|accesstoken|authtoken|refreshtoken|password|passwd|clientsecret|webhooksecret|encryptionkey|hmackey)$/;
113
+ function looksLikeNamedSecret(name, value) {
114
+ const bare = name.toLowerCase().replace(/[_$]/g, '');
115
+ if (!SECRET_NAME.test(bare) || /anon|publishable|public/.test(bare)) return false;
116
+ if (/your|example|placeholder|changeme|x{4,}|here|dummy|redacted|<|\$\{/i.test(value)) return false;
117
+ if (/^(https?:|\/|\.)/i.test(value) || /^eyJ/.test(value)) return false; // an address or a path; JWTs are read above
118
+ return (value.match(/[0-9]/g) || []).length >= 3 && (value.match(/[A-Za-z]/g) || []).length >= 3;
119
+ }
120
+
104
121
  /** First characters and length - enough to find it, too little to use it. */
105
122
  function masked(value) {
106
123
  return value.slice(0, 6) + '... (' + value.length + ' characters, not shown)';
@@ -293,6 +310,22 @@ function secretsIn(rel, source, loadedBy) {
293
310
  }, 'medium');
294
311
  }
295
312
 
313
+ // Never in a test, whose secrets are made up for it and ship nowhere.
314
+ const inTest = /(^|\/)__tests__\/|\.(test|spec|check)\.[cm]?[jt]sx?$/i.test(rel);
315
+ NAMED_SECRET.lastIndex = 0;
316
+ let s;
317
+ while (!inTest && (s = NAMED_SECRET.exec(source))) {
318
+ if (!looksLikeNamedSecret(s[1], s[3])) continue;
319
+ add(s.index + s[0].indexOf(s[3]), s[3], {
320
+ label: 'secret ("' + s[1] + '")',
321
+ opens: 'Its name says it is a secret, and whoever has it can do whatever it signs or unlocks. ' +
322
+ 'Its format is not one I know, so check whether it is a real one.',
323
+ rotate: 'wherever it was issued, issue a new one and revoke this one',
324
+ instead: 'a call to your own server, which holds the secret',
325
+ severity: runs.browser ? 'HIGH' : 'MEDIUM',
326
+ }, 'medium');
327
+ }
328
+
296
329
  // Only where it is read from the environment, and never in a test: found on
297
330
  // Kryptheon's own code, a constant called PUBLIC_SECRET_NAME is a name in a
298
331
  // program, not a variable the build ships - and a test file ships nothing.