@profullstack/threatcrush 0.11.2 → 0.11.3

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/dist/daemon.js CHANGED
@@ -4789,7 +4789,11 @@ var NODE_RULES = [
4789
4789
  severity: "medium",
4790
4790
  languages: ["javascript", "typescript"],
4791
4791
  pattern: /\bBuffer\s*\.\s*allocUnsafe(?:Slow)?\s*\(|\bnew\s+Buffer\s*\(\s*(?![`'"])[a-zA-Z_$0-9]/,
4792
- inherent: true
4792
+ inherent: true,
4793
+ // Filling the buffer yourself is the whole reason to call `allocUnsafe`,
4794
+ // so reporting every call reports correct code. What is left reported is
4795
+ // an allocation whose bytes are never written before it escapes.
4796
+ filledBeforeUseGuard: true
4793
4797
  },
4794
4798
  {
4795
4799
  id: "js-oversized-request-body-limit",
@@ -4899,8 +4903,19 @@ var CODE_RULES = [
4899
4903
  // The tail is what distinguishes assembly from parameterisation. A bound
4900
4904
  // query leaves a comma after the closing quote (`"… = $1", [id]`) and
4901
4905
  // matches none of these.
4906
+ //
4907
+ // The clause alternative anchors the keyword to the *start* of the
4908
+ // concatenated fragment, because that is where a clause being appended
4909
+ // actually sits: `sql + "WHERE id = " + id`, `sql + " ORDER BY " + col`.
4910
+ //
4911
+ // Allowing it anywhere in the fragment made the rule read English. `WHERE`
4912
+ // and `SET` are ordinary words, and without a leading `\b` they were not
4913
+ // even required to be whole ones — "any`where`" and "sub`set`" both
4914
+ // matched. A help string reading "lists them anywhere" was reported as
4915
+ // critical SQL injection, which is the kind of finding that teaches a team
4916
+ // the scanner is not worth reading.
4902
4917
  pattern: new RegExp(
4903
- `${SQL_STRING}\\s*\\+|${SQL_STRING}\\s*%\\s*[\\w(]|${SQL_STRING}\\s*\\.\\s*format\\s*\\(|\\+\\s*(?:"[^"\\n]*|'[^'\\n]*)(?:WHERE|ORDER\\s+BY|VALUES|SET)\\b`,
4918
+ `${SQL_STRING}\\s*\\+|${SQL_STRING}\\s*%\\s*[\\w(]|${SQL_STRING}\\s*\\.\\s*format\\s*\\(|\\+\\s*(?:"|')\\s*\\b(?:WHERE|ORDER\\s+BY|VALUES|SET)\\b`,
4904
4919
  "i"
4905
4920
  )
4906
4921
  },
@@ -4913,7 +4928,26 @@ var CODE_RULES = [
4913
4928
  pattern: new RegExp(
4914
4929
  `\`[^\`\\n]*(?:${SQL_KEYWORDS})\\b[^\`\\n]*\\$\\{|\\bf"[^"\\n]*(?:${SQL_KEYWORDS})\\b[^"\\n]*\\{|\\bf'[^'\\n]*(?:${SQL_KEYWORDS})\\b[^'\\n]*\\{`,
4915
4930
  "i"
4916
- )
4931
+ ),
4932
+ /**
4933
+ * Interpolating a column list is not interpolating a value.
4934
+ *
4935
+ * const COLS = `tld, user_id, owner_email, price_usd`;
4936
+ * get(`SELECT ${COLS} FROM moshpit_tlds WHERE tld = ?`, [tld]);
4937
+ *
4938
+ * That query *is* parameterised: every value the caller supplies rides a
4939
+ * `?`, and the only thing spliced into the text is a constant written a
4940
+ * few lines up. Naming the column list once instead of repeating it in
4941
+ * fourteen queries is ordinary hygiene, and it is the shape this rule met
4942
+ * most often in practice — one repository produced eighteen findings this
4943
+ * way and not one of them could be injected into.
4944
+ *
4945
+ * The guard resolves the interpolations rather than trusting the shape, so
4946
+ * the moment a query mixes a constant with anything else —
4947
+ * `` `SELECT ${COLS} FROM t WHERE id = ${req.query.id}` `` — it is reported
4948
+ * again. `interpolationsAreConstant` requires *every* `${…}` to resolve.
4949
+ */
4950
+ constantInterpolationGuard: true
4917
4951
  },
4918
4952
  {
4919
4953
  id: "sql-format-call",
@@ -4963,7 +4997,22 @@ var CODE_RULES = [
4963
4997
  cwe: "CWE-78",
4964
4998
  severity: "critical",
4965
4999
  languages: ["go"],
4966
- pattern: /\bexec\.Command(?:Context)?\s*\(\s*(?:ctx\s*,\s*)?"(?:\/bin\/)?(?:sh|bash|zsh|cmd|powershell)"\s*,\s*"(?:-c|\/c)"/
5000
+ pattern: /\bexec\.Command(?:Context)?\s*\(\s*(?:ctx\s*,\s*)?"(?:\/bin\/)?(?:sh|bash|zsh|cmd|powershell)"\s*,\s*"(?:-c|\/c)"/,
5001
+ // A call whose whole argv is string literals cannot be injected into.
5002
+ // `exec.Command("cmd", "/c", "ver")` reads the Windows version; there is no
5003
+ // value in it for an attacker to reach, and the consequence above — that a
5004
+ // shell will interpret metacharacters — describes metacharacters nobody can
5005
+ // supply. gosec's G204 draws the line in the same place, and this was the
5006
+ // single finding a Go project got out of a whole scan before declining the
5007
+ // offer, which is an expensive way to report nothing.
5008
+ //
5009
+ // The guard has to end at the closing paren, so a literal followed by
5010
+ // anything else still reports: `"ls " + dir` leaves a `+` before the `)`,
5011
+ // `fmt.Sprintf(…)` leaves an identifier, and a bare variable leaves a name.
5012
+ // `(?:[^"\\]|\\.)*` rather than `[^"]*` so an escaped quote inside a
5013
+ // literal — `"echo \"hi\""` — does not end the literal early and drop the
5014
+ // guard on a line it should have covered.
5015
+ lineGuard: /\bexec\.Command(?:Context)?\s*\(\s*(?:ctx\s*,\s*)?"(?:\/bin\/)?(?:sh|bash|zsh|cmd|powershell)"\s*,\s*"(?:-c|\/c)"\s*(?:,\s*"(?:[^"\\]|\\.)*")*\s*,?\s*\)/
4967
5016
  },
4968
5017
  {
4969
5018
  id: "rb-backtick-interpolation",
@@ -5032,17 +5081,43 @@ var CODE_RULES = [
5032
5081
  cwe: "CWE-79",
5033
5082
  severity: "high",
5034
5083
  languages: ["javascript", "typescript"],
5035
- pattern: /\bdangerouslySetInnerHTML\s*=|\.\s*(?:innerHTML|outerHTML)\s*=\s*(?!\s*['"`]\s*['"`]\s*;?\s*$)|\bdocument\s*\.\s*write(?:ln)?\s*\(|\.\s*insertAdjacentHTML\s*\(/,
5036
5084
  /**
5085
+ * The assignment alternative carries its own exemption, as a lookahead, so
5086
+ * that it is decided per assignment rather than per line.
5087
+ *
5037
5088
  * A whole-statement assignment of a string with no interpolation and no
5038
- * concatenation carries no data, so it cannot carry attacker data. This
5039
- * was the single largest source of noise: a codebase that builds its UI
5040
- * with innerHTML reports every static heading and spinner as XSS, and a
5041
- * rule that flags 40 safe lines to catch one real one gets switched off.
5089
+ * concatenation carries no data, so it cannot carry attacker data. This was
5090
+ * the single largest source of noise: a codebase that builds its UI with
5091
+ * innerHTML reports every static heading and spinner as XSS, and a rule
5092
+ * that flags 40 safe lines to catch one real one gets switched off.
5093
+ *
5094
+ * Two things this has to get right, and a `lineGuard` could get neither:
5095
+ *
5096
+ * A statement ends at its semicolon, not at the newline. Anchoring to `$`
5097
+ * held the exemption for `el.innerHTML = '';` alone on a line and dropped
5098
+ * it the moment anything followed:
5099
+ *
5100
+ * function hide() { out.hidden = true; out.innerHTML = ''; rendered = null; }
5101
+ *
5102
+ * — the same assignment, clearing a node, reported as high-severity XSS
5103
+ * because two neighbours shared its line.
5104
+ *
5105
+ * And an exemption must not become a line-wide amnesty. A guard is tested
5106
+ * against the whole line, so one safe clear would exonerate a real sink
5107
+ * beside it:
5108
+ *
5109
+ * a.innerHTML = ''; b.innerHTML = userInput;
5042
5110
  *
5043
- * Line-scoped on purpose see `lineGuard`.
5111
+ * As a lookahead the regex decides at each `=` it reaches, so the first
5112
+ * assignment is exempt and the second is still reported.
5113
+ *
5114
+ * The whitespace after `=` is matched *inside* the lookahead rather than
5115
+ * before it. Left outside, `\s*` backtracks to zero width, the lookahead
5116
+ * then starts on the space instead of the quote, fails to see a literal,
5117
+ * and the negative lookahead succeeds — reinstating every finding the
5118
+ * exemption was written to remove.
5044
5119
  */
5045
- lineGuard: /(?:innerHTML|outerHTML)\s*=\s*(?:'[^'\\]*'|"[^"\\]*"|`[^`$\\]*`)\s*;?\s*$/
5120
+ pattern: /\bdangerouslySetInnerHTML\s*=|\.\s*(?:innerHTML|outerHTML)\s*=(?!\s*(?:'[^'\\\n]*'|"[^"\\\n]*"|`[^`$\\\n]*`)\s*(?:;|$))|\bdocument\s*\.\s*write(?:ln)?\s*\(|\.\s*insertAdjacentHTML\s*\(/
5046
5121
  },
5047
5122
  {
5048
5123
  id: "java-html-writer-concatenation",
@@ -5980,6 +6055,45 @@ function interpolationsAreConstant(line, fileText) {
5980
6055
  (expr) => /^[A-Za-z_$][\w$]*$/.test(expr) && isConstantString(expr, fileText)
5981
6056
  );
5982
6057
  }
6058
+ var FILL_LOOKAHEAD = 16;
6059
+ var NAME = String.raw`(?<![\w$])([A-Za-z_$][\w$]*)`;
6060
+ var ALLOCATION_BINDING = new RegExp(
6061
+ `${NAME}\\s*=\\s*(?:new\\s+Buffer\\s*\\(|Buffer\\s*\\.\\s*allocUnsafe(?:Slow)?\\s*\\()`
6062
+ );
6063
+ function allocationBinding(line) {
6064
+ return ALLOCATION_BINDING.exec(line)?.[1] ?? null;
6065
+ }
6066
+ var WRITE_SHAPES = [
6067
+ // `src.copy(name, …)` — name is the destination.
6068
+ /\.\s*copy\s*\(\s*([A-Za-z_$][\w$]*)\s*[,)]/g,
6069
+ // `name.fill(…)`, `name.write*(…)`, `name.set(…)`.
6070
+ new RegExp(`${NAME}\\s*\\.\\s*(?:fill|set|write[A-Za-z0-9]*)\\s*\\(`, "g"),
6071
+ // `name[i] = …`, but not `name[i] === …`.
6072
+ new RegExp(`${NAME}\\s*\\[[^\\]\\n]*\\]\\s*=(?!=)`, "g")
6073
+ ];
6074
+ function writesInto(text, name) {
6075
+ for (const shape of WRITE_SHAPES) {
6076
+ shape.lastIndex = 0;
6077
+ for (let found = shape.exec(text); found !== null; found = shape.exec(text)) {
6078
+ if (found[1] === name) return true;
6079
+ }
6080
+ }
6081
+ return false;
6082
+ }
6083
+ function bufferFilledBeforeUse(ctx) {
6084
+ const line = ctx.lines[ctx.index] ?? "";
6085
+ if (/\ballocUnsafe(?:Slow)?\s*\([^)\n]*\)\s*\.\s*fill\s*\(/.test(line)) return true;
6086
+ const name = allocationBinding(line);
6087
+ if (!name) return false;
6088
+ if (writesInto(line.slice(line.indexOf("=") + 1), name)) return true;
6089
+ const last = Math.min(ctx.lines.length - 1, ctx.index + FILL_LOOKAHEAD);
6090
+ for (let i = ctx.index + 1; i <= last; i += 1) {
6091
+ const next = ctx.lines[i] ?? "";
6092
+ if (skippable(next, i, ctx.prose)) continue;
6093
+ if (writesInto(next, name)) return true;
6094
+ }
6095
+ return false;
6096
+ }
5983
6097
  function evaluateRule(rule, ctx) {
5984
6098
  if (rule.languages && !rule.languages.includes(ctx.language)) return null;
5985
6099
  const line = ctx.lines[ctx.index] ?? "";
@@ -5998,6 +6112,7 @@ function evaluateRule(rule, ctx) {
5998
6112
  if (rule.constantInterpolationGuard && interpolationsAreConstant(line, fileTextOf(ctx.lines))) {
5999
6113
  return null;
6000
6114
  }
6115
+ if (rule.filledBeforeUseGuard && bufferFilledBeforeUse(ctx)) return null;
6001
6116
  const guard = rule.guard === void 0 ? GENERIC_GUARD : rule.guard;
6002
6117
  if (guard && (guard.test(line) || guard.test(context))) return null;
6003
6118
  const untrusted = untrustedPatternFor(ctx.language);
@@ -6642,6 +6757,11 @@ var KNOWN_PLACEHOLDERS = [
6642
6757
  function isKnownPlaceholder(text) {
6643
6758
  return KNOWN_PLACEHOLDERS.some((pattern) => pattern.test(text));
6644
6759
  }
6760
+ function isPlaceholderAttribute(line, value) {
6761
+ const at = line.lastIndexOf(value);
6762
+ if (at === -1) return false;
6763
+ return /(?:^|\s)(?:aria-)?placeholder\s*=\s*[{("'`]*$/i.test(line.slice(0, at));
6764
+ }
6645
6765
  function isVariableReference(value) {
6646
6766
  const trimmed = value.trim();
6647
6767
  const braced = /^\$\{\s*([A-Za-z_][\w.]*)\s*(?::?[-=+?]([\s\S]*))?\}$/.exec(trimmed);
@@ -6684,10 +6804,13 @@ function words(text) {
6684
6804
  function isTestFixtureValue(line, value) {
6685
6805
  const valueWords = words(value);
6686
6806
  if (valueWords.some((word) => FIXTURE_STEMS.some((stem) => word.startsWith(stem)))) return true;
6807
+ return describesItsOwnKey(line, value);
6808
+ }
6809
+ function describesItsOwnKey(line, value) {
6687
6810
  if (value.length > 48) return false;
6688
6811
  const valueAt = line.lastIndexOf(value);
6689
6812
  const key = valueAt === -1 ? line : line.slice(0, valueAt);
6690
- const flattened = valueWords.join("");
6813
+ const flattened = words(value).join("");
6691
6814
  return words(key).filter((word) => word.length >= 4 && !KEY_NOISE.has(word)).some((word) => flattened.includes(word));
6692
6815
  }
6693
6816
  function redactSecret(line) {
@@ -6867,9 +6990,9 @@ function languageOfShebang(firstLine) {
6867
6990
  }
6868
6991
  var SUPPRESS_NEXT = /threatcrush-disable-next-line(?:\s+([\w-]+))?/;
6869
6992
  var SUPPRESS_LINE = /threatcrush-disable-line(?:\s+([\w-]+))?/;
6870
- var FOREIGN_CREDENTIAL = /\b(?:nolint:[\w,]*gosec|nosec)\b[^\n]*\bG101\b|\bG101\b[^\n]*\b(?:nolint:[\w,]*gosec|nosec)\b/;
6871
- function foreignCredentialMark(line) {
6872
- return FOREIGN_CREDENTIAL.test(line);
6993
+ var FOREIGN_SECURITY = /\b(?:nolint:[\w,]*gosec|nosec)\b/;
6994
+ function foreignSecurityMark(line) {
6995
+ return FOREIGN_SECURITY.test(line);
6873
6996
  }
6874
6997
  function collectSuppressions(lines) {
6875
6998
  const byLine = /* @__PURE__ */ new Map();
@@ -6895,13 +7018,42 @@ function isSuppressed(suppressions, index, ruleId) {
6895
7018
  }
6896
7019
  function isTestPath(relativePath) {
6897
7020
  const p = relativePath.replace(/\\/g, "/");
6898
- return /(?:^|\/)(?:tests?|__tests__|__mocks__|spec|specs|fixtures?|mocks?|e2e|testdata)\//i.test(p) || /(?:^|\/)(?:test|conftest)_[^/]+$/i.test(p) || /[._-](?:test|spec)\.[a-z]+$/i.test(p) || /_test\.[a-z]+$/i.test(p);
7021
+ return /(?:^|\/)(?:tests?|__tests__|__mocks__|spec|specs|fixtures?|mocks?|e2e|testdata|testutils?|harness)\//i.test(p) || /(?:^|\/)(?:test|conftest)_[^/]+$/i.test(p) || /[._-](?:test|spec)\.[a-z]+$/i.test(p) || /_test\.[a-z]+$/i.test(p);
6899
7022
  }
7023
+ function isDocPath(relativePath) {
7024
+ const p = relativePath.replace(/\\/g, "/");
7025
+ return /(?:^|\/)(?:docs?|examples?|samples?)\//i.test(p) || /\.(?:md|mdx|markdown|rst|adoc)$/i.test(p);
7026
+ }
7027
+ var SOFTENING_ORDER = [
7028
+ "suppressed",
7029
+ "placeholder",
7030
+ "self-describing",
7031
+ "test",
7032
+ "docs"
7033
+ ];
7034
+ function softening(reasons) {
7035
+ return SOFTENING_ORDER.find((reason) => reasons[reason]) ?? null;
7036
+ }
7037
+ var SECRET_SOFTENING = {
7038
+ test: "in a test file \u2014 usually a fixture, still worth confirming it is not a live credential",
7039
+ docs: "in documentation \u2014 usually an illustrative example, still worth confirming it is not a live credential",
7040
+ suppressed: "on a line already marked as a false positive for another linter's security rule",
7041
+ placeholder: "in example text an empty input field shows, not in data",
7042
+ "self-describing": "in a value that repeats the name of the field holding it \u2014 usually a description of a credential rather than one"
7043
+ };
7044
+ var CODE_SOFTENING = {
7045
+ test: "in a test file, where the construct is ordinary",
7046
+ docs: "in documentation or example code, which nothing runs",
7047
+ suppressed: "on a line another linter's security suppression already covers",
7048
+ placeholder: "in example text rather than in data",
7049
+ "self-describing": "in a value that describes itself"
7050
+ };
6900
7051
  function scanText(relativePath, text, language = languageOf(relativePath)) {
6901
7052
  const findings = [];
6902
7053
  const lines = text.split("\n");
6903
7054
  const suppressions = collectSuppressions(lines);
6904
7055
  const inTests = isTestPath(relativePath);
7056
+ const inDocs = isDocPath(relativePath);
6905
7057
  lines.forEach((line, index) => {
6906
7058
  for (const rule of SECRET_RULES) {
6907
7059
  const match = rule.pattern.exec(line);
@@ -6911,18 +7063,23 @@ function scanText(relativePath, text, language = languageOf(relativePath)) {
6911
7063
  const value = match[1] ?? match[0];
6912
7064
  if (isVariableReference(value)) continue;
6913
7065
  if (inTests && rule.keywordShaped && isTestFixtureValue(line, value)) continue;
6914
- const marked = foreignCredentialMark(line);
7066
+ const soft = softening({
7067
+ test: inTests,
7068
+ suppressed: foreignSecurityMark(line),
7069
+ placeholder: isPlaceholderAttribute(line, value),
7070
+ "self-describing": rule.keywordShaped === true && describesItsOwnKey(line, value)
7071
+ });
6915
7072
  findings.push({
6916
7073
  ruleId: rule.id,
6917
7074
  title: rule.name,
6918
7075
  file: relativePath,
6919
7076
  line: index + 1,
6920
- // Reported but not blocking in tests see isTestPath. The same goes
6921
- // for a line another linter's credential rule was already told about.
6922
- severity: inTests || marked ? "low" : rule.severity,
7077
+ // Reported but not blocking wherever context weakens the claim see
7078
+ // `softening`. Never dropped: the count is the same either way.
7079
+ severity: soft ? "low" : rule.severity,
6923
7080
  // A matched credential format is the finding, not a proxy for one.
6924
7081
  confidence: "evidence",
6925
- message: inTests ? `Possible ${rule.name} detected in a test file \u2014 usually a fixture, still worth confirming it is not a live credential` : marked ? `Possible ${rule.name} detected on a line already marked as a false positive for another linter's credential rule` : `Possible ${rule.name} detected`,
7082
+ message: soft ? `Possible ${rule.name} detected ${SECRET_SOFTENING[soft]}` : `Possible ${rule.name} detected`,
6926
7083
  consequence: rule.consequence,
6927
7084
  cwe: rule.cwe,
6928
7085
  excerpt: redactSecret(line.trim()).slice(0, 200),
@@ -6937,14 +7094,19 @@ function scanText(relativePath, text, language = languageOf(relativePath)) {
6937
7094
  if (isSuppressed(suppressions, index, rule.id)) continue;
6938
7095
  const match = evaluateRule(rule, { lines, index, language, prose });
6939
7096
  if (!match) continue;
7097
+ const soft = softening({
7098
+ test: inTests,
7099
+ docs: inDocs,
7100
+ suppressed: foreignSecurityMark(lines[index] ?? "")
7101
+ });
6940
7102
  findings.push({
6941
7103
  ruleId: rule.id,
6942
7104
  title: rule.title,
6943
7105
  file: relativePath,
6944
7106
  line: index + 1,
6945
- severity: match.severity,
7107
+ severity: soft ? "low" : match.severity,
6946
7108
  confidence: match.confidence,
6947
- message: `${rule.title} (${rule.cwe})`,
7109
+ message: soft ? `${rule.title} (${rule.cwe}) \u2014 ${CODE_SOFTENING[soft]}` : `${rule.title} (${rule.cwe})`,
6948
7110
  consequence: rule.consequence,
6949
7111
  cwe: rule.cwe,
6950
7112
  excerpt: (lines[index] ?? "").trim().slice(0, 200),
@@ -6955,14 +7117,19 @@ function scanText(relativePath, text, language = languageOf(relativePath)) {
6955
7117
  for (const match of evaluateTemplateRules(extensionOf(relativePath), lines)) {
6956
7118
  const index = match.line - 1;
6957
7119
  if (isSuppressed(suppressions, index, match.rule.id)) continue;
7120
+ const soft = softening({
7121
+ test: inTests,
7122
+ docs: inDocs,
7123
+ suppressed: foreignSecurityMark(lines[index] ?? "")
7124
+ });
6958
7125
  findings.push({
6959
7126
  ruleId: match.rule.id,
6960
7127
  title: match.rule.title,
6961
7128
  file: relativePath,
6962
7129
  line: match.line,
6963
- severity: match.severity,
7130
+ severity: soft ? "low" : match.severity,
6964
7131
  confidence: "pattern",
6965
- message: `${match.rule.title} (${match.rule.cwe})`,
7132
+ message: soft ? `${match.rule.title} (${match.rule.cwe}) \u2014 ${CODE_SOFTENING[soft]}` : `${match.rule.title} (${match.rule.cwe})`,
6966
7133
  consequence: match.rule.consequence,
6967
7134
  cwe: match.rule.cwe,
6968
7135
  excerpt: (lines[index] ?? "").trim().slice(0, 200),