@profullstack/threatcrush 0.11.2 → 0.11.4

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
@@ -2150,6 +2150,9 @@ bus.setMaxListeners(50);
2150
2150
  var import_better_sqlite3 = __toESM(require("better-sqlite3"));
2151
2151
  var db = null;
2152
2152
  var dbUnavailable = false;
2153
+ function isStateDbAvailable() {
2154
+ return db !== null;
2155
+ }
2153
2156
  function initStateDB(dbPath = "/var/lib/threatcrush/state.db") {
2154
2157
  if (db) return db;
2155
2158
  if (dbUnavailable) {
@@ -2427,7 +2430,8 @@ var IpcServer = class {
2427
2430
  socket: PATHS.socket
2428
2431
  },
2429
2432
  modules: this.moduleHost.summary(),
2430
- counters: { ...this.counters }
2433
+ counters: { ...this.counters },
2434
+ stateDb: isStateDbAvailable()
2431
2435
  };
2432
2436
  return this.send(client, { id: req.id, ok: true, result: status });
2433
2437
  }
@@ -4789,7 +4793,11 @@ var NODE_RULES = [
4789
4793
  severity: "medium",
4790
4794
  languages: ["javascript", "typescript"],
4791
4795
  pattern: /\bBuffer\s*\.\s*allocUnsafe(?:Slow)?\s*\(|\bnew\s+Buffer\s*\(\s*(?![`'"])[a-zA-Z_$0-9]/,
4792
- inherent: true
4796
+ inherent: true,
4797
+ // Filling the buffer yourself is the whole reason to call `allocUnsafe`,
4798
+ // so reporting every call reports correct code. What is left reported is
4799
+ // an allocation whose bytes are never written before it escapes.
4800
+ filledBeforeUseGuard: true
4793
4801
  },
4794
4802
  {
4795
4803
  id: "js-oversized-request-body-limit",
@@ -4899,8 +4907,19 @@ var CODE_RULES = [
4899
4907
  // The tail is what distinguishes assembly from parameterisation. A bound
4900
4908
  // query leaves a comma after the closing quote (`"… = $1", [id]`) and
4901
4909
  // matches none of these.
4910
+ //
4911
+ // The clause alternative anchors the keyword to the *start* of the
4912
+ // concatenated fragment, because that is where a clause being appended
4913
+ // actually sits: `sql + "WHERE id = " + id`, `sql + " ORDER BY " + col`.
4914
+ //
4915
+ // Allowing it anywhere in the fragment made the rule read English. `WHERE`
4916
+ // and `SET` are ordinary words, and without a leading `\b` they were not
4917
+ // even required to be whole ones — "any`where`" and "sub`set`" both
4918
+ // matched. A help string reading "lists them anywhere" was reported as
4919
+ // critical SQL injection, which is the kind of finding that teaches a team
4920
+ // the scanner is not worth reading.
4902
4921
  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`,
4922
+ `${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
4923
  "i"
4905
4924
  )
4906
4925
  },
@@ -4913,7 +4932,26 @@ var CODE_RULES = [
4913
4932
  pattern: new RegExp(
4914
4933
  `\`[^\`\\n]*(?:${SQL_KEYWORDS})\\b[^\`\\n]*\\$\\{|\\bf"[^"\\n]*(?:${SQL_KEYWORDS})\\b[^"\\n]*\\{|\\bf'[^'\\n]*(?:${SQL_KEYWORDS})\\b[^'\\n]*\\{`,
4915
4934
  "i"
4916
- )
4935
+ ),
4936
+ /**
4937
+ * Interpolating a column list is not interpolating a value.
4938
+ *
4939
+ * const COLS = `tld, user_id, owner_email, price_usd`;
4940
+ * get(`SELECT ${COLS} FROM moshpit_tlds WHERE tld = ?`, [tld]);
4941
+ *
4942
+ * That query *is* parameterised: every value the caller supplies rides a
4943
+ * `?`, and the only thing spliced into the text is a constant written a
4944
+ * few lines up. Naming the column list once instead of repeating it in
4945
+ * fourteen queries is ordinary hygiene, and it is the shape this rule met
4946
+ * most often in practice — one repository produced eighteen findings this
4947
+ * way and not one of them could be injected into.
4948
+ *
4949
+ * The guard resolves the interpolations rather than trusting the shape, so
4950
+ * the moment a query mixes a constant with anything else —
4951
+ * `` `SELECT ${COLS} FROM t WHERE id = ${req.query.id}` `` — it is reported
4952
+ * again. `interpolationsAreConstant` requires *every* `${…}` to resolve.
4953
+ */
4954
+ constantInterpolationGuard: true
4917
4955
  },
4918
4956
  {
4919
4957
  id: "sql-format-call",
@@ -4945,7 +4983,12 @@ var CODE_RULES = [
4945
4983
  severity: "critical",
4946
4984
  languages: ["javascript", "typescript"],
4947
4985
  pattern: /\b(?:exec|execSync|spawn|spawnSync)\s*\(\s*(?:`[^`]*\$\{|['"][^'"]*['"]\s*\+|[a-zA-Z_$][\w$]*\s*\+)/,
4948
- constantInterpolationGuard: true
4986
+ constantInterpolationGuard: true,
4987
+ // Interpolation is common in locally run CLI and installer code. Report
4988
+ // command execution as an injection vulnerability only when the nearby
4989
+ // code shows a caller-controlled source, rather than treating every
4990
+ // internally assembled command as attacker input.
4991
+ needsContext: true
4949
4992
  },
4950
4993
  {
4951
4994
  id: "py-shell-command-string",
@@ -4963,7 +5006,22 @@ var CODE_RULES = [
4963
5006
  cwe: "CWE-78",
4964
5007
  severity: "critical",
4965
5008
  languages: ["go"],
4966
- pattern: /\bexec\.Command(?:Context)?\s*\(\s*(?:ctx\s*,\s*)?"(?:\/bin\/)?(?:sh|bash|zsh|cmd|powershell)"\s*,\s*"(?:-c|\/c)"/
5009
+ pattern: /\bexec\.Command(?:Context)?\s*\(\s*(?:ctx\s*,\s*)?"(?:\/bin\/)?(?:sh|bash|zsh|cmd|powershell)"\s*,\s*"(?:-c|\/c)"/,
5010
+ // A call whose whole argv is string literals cannot be injected into.
5011
+ // `exec.Command("cmd", "/c", "ver")` reads the Windows version; there is no
5012
+ // value in it for an attacker to reach, and the consequence above — that a
5013
+ // shell will interpret metacharacters — describes metacharacters nobody can
5014
+ // supply. gosec's G204 draws the line in the same place, and this was the
5015
+ // single finding a Go project got out of a whole scan before declining the
5016
+ // offer, which is an expensive way to report nothing.
5017
+ //
5018
+ // The guard has to end at the closing paren, so a literal followed by
5019
+ // anything else still reports: `"ls " + dir` leaves a `+` before the `)`,
5020
+ // `fmt.Sprintf(…)` leaves an identifier, and a bare variable leaves a name.
5021
+ // `(?:[^"\\]|\\.)*` rather than `[^"]*` so an escaped quote inside a
5022
+ // literal — `"echo \"hi\""` — does not end the literal early and drop the
5023
+ // guard on a line it should have covered.
5024
+ lineGuard: /\bexec\.Command(?:Context)?\s*\(\s*(?:ctx\s*,\s*)?"(?:\/bin\/)?(?:sh|bash|zsh|cmd|powershell)"\s*,\s*"(?:-c|\/c)"\s*(?:,\s*"(?:[^"\\]|\\.)*")*\s*,?\s*\)/
4967
5025
  },
4968
5026
  {
4969
5027
  id: "rb-backtick-interpolation",
@@ -5032,17 +5090,44 @@ var CODE_RULES = [
5032
5090
  cwe: "CWE-79",
5033
5091
  severity: "high",
5034
5092
  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
5093
  /**
5094
+ * The assignment alternative carries its own exemption, as a lookahead, so
5095
+ * that it is decided per assignment rather than per line.
5096
+ *
5037
5097
  * 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.
5098
+ * concatenation carries no data, so it cannot carry attacker data. This was
5099
+ * the single largest source of noise: a codebase that builds its UI with
5100
+ * innerHTML reports every static heading and spinner as XSS, and a rule
5101
+ * that flags 40 safe lines to catch one real one gets switched off.
5102
+ *
5103
+ * Two things this has to get right, and a `lineGuard` could get neither:
5104
+ *
5105
+ * A statement ends at its semicolon, not at the newline. Anchoring to `$`
5106
+ * held the exemption for `el.innerHTML = '';` alone on a line and dropped
5107
+ * it the moment anything followed:
5108
+ *
5109
+ * function hide() { out.hidden = true; out.innerHTML = ''; rendered = null; }
5110
+ *
5111
+ * — the same assignment, clearing a node, reported as high-severity XSS
5112
+ * because two neighbours shared its line.
5113
+ *
5114
+ * And an exemption must not become a line-wide amnesty. A guard is tested
5115
+ * against the whole line, so one safe clear would exonerate a real sink
5116
+ * beside it:
5117
+ *
5118
+ * a.innerHTML = ''; b.innerHTML = userInput;
5119
+ *
5120
+ * As a lookahead the regex decides at each `=` it reaches, so the first
5121
+ * assignment is exempt and the second is still reported.
5042
5122
  *
5043
- * Line-scoped on purpose see `lineGuard`.
5123
+ * The whitespace after `=` is matched *inside* the lookahead rather than
5124
+ * before it. Left outside, `\s*` backtracks to zero width, the lookahead
5125
+ * then starts on the space instead of the quote, fails to see a literal,
5126
+ * and the negative lookahead succeeds — reinstating every finding the
5127
+ * exemption was written to remove.
5044
5128
  */
5045
- lineGuard: /(?:innerHTML|outerHTML)\s*=\s*(?:'[^'\\]*'|"[^"\\]*"|`[^`$\\]*`)\s*;?\s*$/
5129
+ pattern: /\bdangerouslySetInnerHTML\s*=|\.\s*(?:innerHTML|outerHTML)\s*=(?!\s*(?:'[^'\\\n]*'|"[^"\\\n]*"|`[^`$\\\n]*`)\s*(?:;|$))|\bdocument\s*\.\s*write(?:ln)?\s*\(|\.\s*insertAdjacentHTML\s*\(/,
5130
+ sanitizedHtmlGuard: true
5046
5131
  },
5047
5132
  {
5048
5133
  id: "java-html-writer-concatenation",
@@ -5121,7 +5206,8 @@ var CODE_RULES = [
5121
5206
  severity: "medium",
5122
5207
  languages: ["javascript", "typescript"],
5123
5208
  pattern: /\b(?:res|response)\s*\.\s*redirect\s*\(\s*[a-zA-Z_$][\w$]*\s*\)|\bwindow\s*\.\s*location(?:\s*\.\s*(?:href|replace))?\s*(?:=\s*[a-zA-Z_$]|\(\s*[a-zA-Z_$][\w$]*\s*\))/,
5124
- needsContext: true
5209
+ needsContext: true,
5210
+ safeRedirectGuard: true
5125
5211
  },
5126
5212
  // ── Deserialisation ──────────────────────────────────────────────────────
5127
5213
  {
@@ -5321,7 +5407,7 @@ var CODE_RULES = [
5321
5407
  // the pattern is written. `other` also covers C++, Rust and Zig, which
5322
5408
  // share the cast-then-deref spelling.
5323
5409
  languages: ["javascript", "typescript", "python", "ruby", "go", "java", "php"],
5324
- pattern: /\([^)\n]*[+*]\s*\)\s*[+*]|\([^)\n]*\{\d+,\}\s*\)\s*[+*{]/
5410
+ pattern: /(?:^|[=(:,]\s*)\/(?:\\.|[^/\\\n])*\([^()\n]*[+*][^()\n]*\)\s*(?:[+*]|\{\d+,\})|\b(?:new\s+RegExp|RegExp|re\.compile|regexp\.(?:Compile|MustCompile)|Pattern\.compile)\s*\(\s*(?:r)?["'][^"'\n]*\([^()\n]*[+*][^()\n]*\)\s*(?:[+*]|\{\d+,\})/
5325
5411
  },
5326
5412
  // ── Temporary files ──────────────────────────────────────────────────────
5327
5413
  {
@@ -5330,10 +5416,13 @@ var CODE_RULES = [
5330
5416
  consequence: "A predictable name in a world-writable directory is a symlink attack: an attacker pre-creates the path and your process writes through it.",
5331
5417
  cwe: "CWE-377",
5332
5418
  severity: "medium",
5333
- // A hardcoded path under /tmp is the finding whether or not it is
5334
- // formatted: `"/tmp/application.log.tmp"` is worse than the PID-based one,
5335
- // because every process on the host can predict it exactly.
5336
- pattern: /\btempfile\.mktemp\s*\(|\bos\.tmpnam\s*\(|['"]\/tmp\/[^'"\n]+['"]|['"]\/tmp\/[^'"\n]*\{|\bFile\.createTempFile\s*\(/
5419
+ // `File.createTempFile` is specifically the safe Java API, and a string
5420
+ // containing `/tmp/` is not necessarily a write. Detect unsafe name
5421
+ // generators and actual writes to a literal temp path instead. Shell
5422
+ // redirections are handled by the shell-specific rule below.
5423
+ pattern: new RegExp(
5424
+ String.raw`\btempfile\.mktemp\s*\(|\bos\.tmpnam\s*\(|\b(?:writeFile(?:Sync)?|appendFile(?:Sync)?|createWriteStream|FileOutputStream|FileWriter|os\.OpenFile|open)\s*\([^\n)]*['"]\/tmp\/[^'"\n]+['"]`
5425
+ )
5337
5426
  },
5338
5427
  // ── Information exposure ─────────────────────────────────────────────────
5339
5428
  {
@@ -5386,7 +5475,6 @@ var CODE_RULES = [
5386
5475
  // of privileged work actually happens, and they run as whoever invoked them.
5387
5476
  {
5388
5477
  id: "sh-remote-script-execution",
5389
- inherent: true,
5390
5478
  title: "network output piped into a shell",
5391
5479
  consequence: "Whatever that URL serves at the moment this runs is executed as the invoking user. There is no version, no signature, and no review \u2014 a compromise of the host, or anyone able to answer for it, is a compromise of every machine that runs the script.",
5392
5480
  cwe: "CWE-494",
@@ -5395,9 +5483,13 @@ var CODE_RULES = [
5395
5483
  // The pipe must be the *next* thing: `curl -o f url && sh f` is a different
5396
5484
  // (and checkable) shape, and `curl url | jq` is not an execution at all.
5397
5485
  pattern: /\b(?:curl|wget)\b[^|\n]*\|\s*(?:sudo\s+(?:-\S+\s+)*)?(?:\/bin\/|\/usr\/bin\/)?(?:ba|da|k|z|a)?sh\b/,
5398
- // Nothing on this line can exonerate it. Integrity checking happens in a
5399
- // separate step by construction, so a window guard would only mislead.
5400
- guard: false
5486
+ // This is a supply-chain review item, not proof that the repository is
5487
+ // compromised. A literal HTTPS installer URL is capped at medium; it only
5488
+ // escalates when nearby evidence shows that input can choose the download.
5489
+ guard: false,
5490
+ // Installer instructions printed by the script do not execute. Matching
5491
+ // them made a script report its own documentation as a network pipeline.
5492
+ lineGuard: /^\s*(?:echo|printf|say|info|warn|error)\s+["'][^"'\n]*\b(?:curl|wget)\b/
5401
5493
  },
5402
5494
  {
5403
5495
  id: "sh-eval-expansion",
@@ -5973,6 +6065,57 @@ function isConstantString(name, fileText) {
5973
6065
  `\\bconst\\s+${escaped}\\s*(?::[^=\\n]+)?=\\s*(?:'[^'\\n]*'|"[^"\\n]*"|\`[^\`$\\n]*\`)`
5974
6066
  ).test(fileText);
5975
6067
  }
6068
+ function hasSanitizedHtmlValue(line, fileText) {
6069
+ const direct = /\b__html\s*:\s*(?:[A-Za-z_$][\w$]*\s*\.\s*)?(?:sanitize\w*|escape\w*|serializeJsonForHtml|renderSanitizedMarkdown)\s*\(/i;
6070
+ if (direct.test(line)) return true;
6071
+ const value = /\b__html\s*:\s*([A-Za-z_$][\w$]*)\b/.exec(line)?.[1];
6072
+ if (!value) return false;
6073
+ const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6074
+ return new RegExp(
6075
+ `\\bconst\\s+${escaped}\\s*=[^\\n;]*(?:[A-Za-z_$][\\w$]*\\s*\\.\\s*)?(?:sanitize\\w*|escape\\w*|serializeJsonForHtml|renderSanitizedMarkdown)\\s*\\(`,
6076
+ "i"
6077
+ ).test(fileText);
6078
+ }
6079
+ function isIdentifierStart(char) {
6080
+ if (!char) return false;
6081
+ const code = char.charCodeAt(0);
6082
+ return char === "$" || char === "_" || code >= 65 && code <= 90 || code >= 97 && code <= 122;
6083
+ }
6084
+ function isIdentifierPart(char) {
6085
+ if (isIdentifierStart(char)) return true;
6086
+ if (!char) return false;
6087
+ const code = char.charCodeAt(0);
6088
+ return code >= 48 && code <= 57;
6089
+ }
6090
+ function identifierAfter(text, start) {
6091
+ let index = start;
6092
+ while (text[index] === " " || text[index] === " ") index += 1;
6093
+ if (!isIdentifierStart(text[index])) return null;
6094
+ const from = index;
6095
+ while (isIdentifierPart(text[index])) index += 1;
6096
+ return text.slice(from, index);
6097
+ }
6098
+ function hasSafeRedirectValue(line, fileText) {
6099
+ const location = line.indexOf("window.location");
6100
+ const redirect = line.indexOf(".redirect");
6101
+ const valueStart = location >= 0 ? line.indexOf("=", location) + 1 : redirect >= 0 ? line.indexOf("(", redirect) + 1 : 0;
6102
+ if (valueStart === 0) return false;
6103
+ const value = identifierAfter(line, valueStart);
6104
+ if (!value) return false;
6105
+ const declaration = `const ${value}`;
6106
+ for (const candidate of fileText.split("\n")) {
6107
+ const at = candidate.indexOf(declaration);
6108
+ if (at === -1 || isIdentifierPart(candidate[at - 1]) || isIdentifierPart(candidate[at + declaration.length])) {
6109
+ continue;
6110
+ }
6111
+ const equals = candidate.indexOf("=", at + declaration.length);
6112
+ const initializer = candidate.slice(equals + 1);
6113
+ if (equals !== -1 && ["safeRedirectPath(", "safeRedirectUrl(", "safeRedirectURL("].some((call) => initializer.includes(call))) {
6114
+ return true;
6115
+ }
6116
+ }
6117
+ return false;
6118
+ }
5976
6119
  function interpolationsAreConstant(line, fileText) {
5977
6120
  const found = interpolations(line);
5978
6121
  if (!found) return false;
@@ -5980,6 +6123,45 @@ function interpolationsAreConstant(line, fileText) {
5980
6123
  (expr) => /^[A-Za-z_$][\w$]*$/.test(expr) && isConstantString(expr, fileText)
5981
6124
  );
5982
6125
  }
6126
+ var FILL_LOOKAHEAD = 16;
6127
+ var NAME = String.raw`(?<![\w$])([A-Za-z_$][\w$]*)`;
6128
+ var ALLOCATION_BINDING = new RegExp(
6129
+ `${NAME}\\s*=\\s*(?:new\\s+Buffer\\s*\\(|Buffer\\s*\\.\\s*allocUnsafe(?:Slow)?\\s*\\()`
6130
+ );
6131
+ function allocationBinding(line) {
6132
+ return ALLOCATION_BINDING.exec(line)?.[1] ?? null;
6133
+ }
6134
+ var WRITE_SHAPES = [
6135
+ // `src.copy(name, …)` — name is the destination.
6136
+ /\.\s*copy\s*\(\s*([A-Za-z_$][\w$]*)\s*[,)]/g,
6137
+ // `name.fill(…)`, `name.write*(…)`, `name.set(…)`.
6138
+ new RegExp(`${NAME}\\s*\\.\\s*(?:fill|set|write[A-Za-z0-9]*)\\s*\\(`, "g"),
6139
+ // `name[i] = …`, but not `name[i] === …`.
6140
+ new RegExp(`${NAME}\\s*\\[[^\\]\\n]*\\]\\s*=(?!=)`, "g")
6141
+ ];
6142
+ function writesInto(text, name) {
6143
+ for (const shape of WRITE_SHAPES) {
6144
+ shape.lastIndex = 0;
6145
+ for (let found = shape.exec(text); found !== null; found = shape.exec(text)) {
6146
+ if (found[1] === name) return true;
6147
+ }
6148
+ }
6149
+ return false;
6150
+ }
6151
+ function bufferFilledBeforeUse(ctx) {
6152
+ const line = ctx.lines[ctx.index] ?? "";
6153
+ if (/\ballocUnsafe(?:Slow)?\s*\([^)\n]*\)\s*\.\s*fill\s*\(/.test(line)) return true;
6154
+ const name = allocationBinding(line);
6155
+ if (!name) return false;
6156
+ if (writesInto(line.slice(line.indexOf("=") + 1), name)) return true;
6157
+ const last = Math.min(ctx.lines.length - 1, ctx.index + FILL_LOOKAHEAD);
6158
+ for (let i = ctx.index + 1; i <= last; i += 1) {
6159
+ const next = ctx.lines[i] ?? "";
6160
+ if (skippable(next, i, ctx.prose)) continue;
6161
+ if (writesInto(next, name)) return true;
6162
+ }
6163
+ return false;
6164
+ }
5983
6165
  function evaluateRule(rule, ctx) {
5984
6166
  if (rule.languages && !rule.languages.includes(ctx.language)) return null;
5985
6167
  const line = ctx.lines[ctx.index] ?? "";
@@ -5998,6 +6180,13 @@ function evaluateRule(rule, ctx) {
5998
6180
  if (rule.constantInterpolationGuard && interpolationsAreConstant(line, fileTextOf(ctx.lines))) {
5999
6181
  return null;
6000
6182
  }
6183
+ if (rule.sanitizedHtmlGuard && hasSanitizedHtmlValue(line, fileTextOf(ctx.lines))) {
6184
+ return null;
6185
+ }
6186
+ if (rule.safeRedirectGuard && hasSafeRedirectValue(line, fileTextOf(ctx.lines))) {
6187
+ return null;
6188
+ }
6189
+ if (rule.filledBeforeUseGuard && bufferFilledBeforeUse(ctx)) return null;
6001
6190
  const guard = rule.guard === void 0 ? GENERIC_GUARD : rule.guard;
6002
6191
  if (guard && (guard.test(line) || guard.test(context))) return null;
6003
6192
  const untrusted = untrustedPatternFor(ctx.language);
@@ -6362,6 +6551,10 @@ function detectTyposquat(name, ecosystem) {
6362
6551
  return null;
6363
6552
  }
6364
6553
  var LIFECYCLE_SCRIPTS = ["preinstall", "install", "postinstall", "prepare", "prepublish"];
6554
+ function isRiskyLifecycleScript(body) {
6555
+ if (typeof body !== "string") return false;
6556
+ return /\b(?:curl|wget)\b[^\n|]*\|\s*(?:\w*sh|bash|node|python)\b|\b(?:curl|wget)\b[^\n]*(?:https?:|--upload-file)|\b(?:node|python|ruby|perl)\s+-e\b|\b(?:base64|openssl)\b[^\n]*(?:-d|--decode)|\beval\s+|\bchmod\s+(?:\S+\s+)*(?:777|a\+rwx)\b/i.test(body);
6557
+ }
6365
6558
  function scanPackageJson(text) {
6366
6559
  const findings = [];
6367
6560
  const lines = text.split("\n");
@@ -6413,13 +6606,14 @@ function scanPackageJson(text) {
6413
6606
  if (scripts && typeof scripts === "object") {
6414
6607
  for (const [name, body] of Object.entries(scripts)) {
6415
6608
  if (!LIFECYCLE_SCRIPTS.includes(name)) continue;
6609
+ if (!isRiskyLifecycleScript(body)) continue;
6416
6610
  findings.push({
6417
- ruleId: "manifest-install-lifecycle-script",
6418
- title: "install-time lifecycle script",
6611
+ ruleId: "manifest-risky-install-lifecycle-script",
6612
+ title: "risky install-time lifecycle script",
6419
6613
  line: lineOf(name),
6420
6614
  severity: "medium",
6421
6615
  cwe: "CWE-506",
6422
- message: `"${name}" runs automatically on install: ${String(body).slice(0, 120)}`,
6616
+ message: `"${name}" runs automatically on install and contains a network or code-execution primitive: ${String(body).slice(0, 120)}`,
6423
6617
  consequence: "Lifecycle scripts run with the installing user\u2019s privileges and network access, before any code is reviewed. It is the execution vector every notable npm compromise has used.",
6424
6618
  excerpt: (lines[lineOf(name) - 1] ?? "").trim()
6425
6619
  });
@@ -6642,6 +6836,11 @@ var KNOWN_PLACEHOLDERS = [
6642
6836
  function isKnownPlaceholder(text) {
6643
6837
  return KNOWN_PLACEHOLDERS.some((pattern) => pattern.test(text));
6644
6838
  }
6839
+ function isPlaceholderAttribute(line, value) {
6840
+ const at = line.lastIndexOf(value);
6841
+ if (at === -1) return false;
6842
+ return /(?:^|\s)(?:aria-)?placeholder\s*=\s*[{("'`]*$/i.test(line.slice(0, at));
6843
+ }
6645
6844
  function isVariableReference(value) {
6646
6845
  const trimmed = value.trim();
6647
6846
  const braced = /^\$\{\s*([A-Za-z_][\w.]*)\s*(?::?[-=+?]([\s\S]*))?\}$/.exec(trimmed);
@@ -6684,10 +6883,13 @@ function words(text) {
6684
6883
  function isTestFixtureValue(line, value) {
6685
6884
  const valueWords = words(value);
6686
6885
  if (valueWords.some((word) => FIXTURE_STEMS.some((stem) => word.startsWith(stem)))) return true;
6886
+ return describesItsOwnKey(line, value);
6887
+ }
6888
+ function describesItsOwnKey(line, value) {
6687
6889
  if (value.length > 48) return false;
6688
6890
  const valueAt = line.lastIndexOf(value);
6689
6891
  const key = valueAt === -1 ? line : line.slice(0, valueAt);
6690
- const flattened = valueWords.join("");
6892
+ const flattened = words(value).join("");
6691
6893
  return words(key).filter((word) => word.length >= 4 && !KEY_NOISE.has(word)).some((word) => flattened.includes(word));
6692
6894
  }
6693
6895
  function redactSecret(line) {
@@ -6867,9 +7069,9 @@ function languageOfShebang(firstLine) {
6867
7069
  }
6868
7070
  var SUPPRESS_NEXT = /threatcrush-disable-next-line(?:\s+([\w-]+))?/;
6869
7071
  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);
7072
+ var FOREIGN_SECURITY = /\b(?:nolint:[\w,]*gosec|nosec)\b/;
7073
+ function foreignSecurityMark(line) {
7074
+ return FOREIGN_SECURITY.test(line);
6873
7075
  }
6874
7076
  function collectSuppressions(lines) {
6875
7077
  const byLine = /* @__PURE__ */ new Map();
@@ -6895,13 +7097,42 @@ function isSuppressed(suppressions, index, ruleId) {
6895
7097
  }
6896
7098
  function isTestPath(relativePath) {
6897
7099
  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);
7100
+ 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);
7101
+ }
7102
+ function isDocPath(relativePath) {
7103
+ const p = relativePath.replace(/\\/g, "/");
7104
+ return /(?:^|\/)(?:docs?|examples?|samples?)\//i.test(p) || /\.(?:md|mdx|markdown|rst|adoc)$/i.test(p);
6899
7105
  }
7106
+ var SOFTENING_ORDER = [
7107
+ "suppressed",
7108
+ "placeholder",
7109
+ "self-describing",
7110
+ "test",
7111
+ "docs"
7112
+ ];
7113
+ function softening(reasons) {
7114
+ return SOFTENING_ORDER.find((reason) => reasons[reason]) ?? null;
7115
+ }
7116
+ var SECRET_SOFTENING = {
7117
+ test: "in a test file \u2014 usually a fixture, still worth confirming it is not a live credential",
7118
+ docs: "in documentation \u2014 usually an illustrative example, still worth confirming it is not a live credential",
7119
+ suppressed: "on a line already marked as a false positive for another linter's security rule",
7120
+ placeholder: "in example text an empty input field shows, not in data",
7121
+ "self-describing": "in a value that repeats the name of the field holding it \u2014 usually a description of a credential rather than one"
7122
+ };
7123
+ var CODE_SOFTENING = {
7124
+ test: "in a test file, where the construct is ordinary",
7125
+ docs: "in documentation or example code, which nothing runs",
7126
+ suppressed: "on a line another linter's security suppression already covers",
7127
+ placeholder: "in example text rather than in data",
7128
+ "self-describing": "in a value that describes itself"
7129
+ };
6900
7130
  function scanText(relativePath, text, language = languageOf(relativePath)) {
6901
7131
  const findings = [];
6902
7132
  const lines = text.split("\n");
6903
7133
  const suppressions = collectSuppressions(lines);
6904
7134
  const inTests = isTestPath(relativePath);
7135
+ const inDocs = isDocPath(relativePath);
6905
7136
  lines.forEach((line, index) => {
6906
7137
  for (const rule of SECRET_RULES) {
6907
7138
  const match = rule.pattern.exec(line);
@@ -6911,18 +7142,23 @@ function scanText(relativePath, text, language = languageOf(relativePath)) {
6911
7142
  const value = match[1] ?? match[0];
6912
7143
  if (isVariableReference(value)) continue;
6913
7144
  if (inTests && rule.keywordShaped && isTestFixtureValue(line, value)) continue;
6914
- const marked = foreignCredentialMark(line);
7145
+ const soft = softening({
7146
+ test: inTests,
7147
+ suppressed: foreignSecurityMark(line),
7148
+ placeholder: isPlaceholderAttribute(line, value),
7149
+ "self-describing": rule.keywordShaped === true && describesItsOwnKey(line, value)
7150
+ });
6915
7151
  findings.push({
6916
7152
  ruleId: rule.id,
6917
7153
  title: rule.name,
6918
7154
  file: relativePath,
6919
7155
  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,
7156
+ // Reported but not blocking wherever context weakens the claim see
7157
+ // `softening`. Never dropped: the count is the same either way.
7158
+ severity: soft ? "low" : rule.severity,
6923
7159
  // A matched credential format is the finding, not a proxy for one.
6924
7160
  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`,
7161
+ message: soft ? `Possible ${rule.name} detected ${SECRET_SOFTENING[soft]}` : `Possible ${rule.name} detected`,
6926
7162
  consequence: rule.consequence,
6927
7163
  cwe: rule.cwe,
6928
7164
  excerpt: redactSecret(line.trim()).slice(0, 200),
@@ -6937,14 +7173,19 @@ function scanText(relativePath, text, language = languageOf(relativePath)) {
6937
7173
  if (isSuppressed(suppressions, index, rule.id)) continue;
6938
7174
  const match = evaluateRule(rule, { lines, index, language, prose });
6939
7175
  if (!match) continue;
7176
+ const soft = softening({
7177
+ test: inTests,
7178
+ docs: inDocs,
7179
+ suppressed: foreignSecurityMark(lines[index] ?? "")
7180
+ });
6940
7181
  findings.push({
6941
7182
  ruleId: rule.id,
6942
7183
  title: rule.title,
6943
7184
  file: relativePath,
6944
7185
  line: index + 1,
6945
- severity: match.severity,
7186
+ severity: soft ? "low" : match.severity,
6946
7187
  confidence: match.confidence,
6947
- message: `${rule.title} (${rule.cwe})`,
7188
+ message: soft ? `${rule.title} (${rule.cwe}) \u2014 ${CODE_SOFTENING[soft]}` : `${rule.title} (${rule.cwe})`,
6948
7189
  consequence: rule.consequence,
6949
7190
  cwe: rule.cwe,
6950
7191
  excerpt: (lines[index] ?? "").trim().slice(0, 200),
@@ -6955,14 +7196,19 @@ function scanText(relativePath, text, language = languageOf(relativePath)) {
6955
7196
  for (const match of evaluateTemplateRules(extensionOf(relativePath), lines)) {
6956
7197
  const index = match.line - 1;
6957
7198
  if (isSuppressed(suppressions, index, match.rule.id)) continue;
7199
+ const soft = softening({
7200
+ test: inTests,
7201
+ docs: inDocs,
7202
+ suppressed: foreignSecurityMark(lines[index] ?? "")
7203
+ });
6958
7204
  findings.push({
6959
7205
  ruleId: match.rule.id,
6960
7206
  title: match.rule.title,
6961
7207
  file: relativePath,
6962
7208
  line: match.line,
6963
- severity: match.severity,
7209
+ severity: soft ? "low" : match.severity,
6964
7210
  confidence: "pattern",
6965
- message: `${match.rule.title} (${match.rule.cwe})`,
7211
+ message: soft ? `${match.rule.title} (${match.rule.cwe}) \u2014 ${CODE_SOFTENING[soft]}` : `${match.rule.title} (${match.rule.cwe})`,
6966
7212
  consequence: match.rule.consequence,
6967
7213
  cwe: match.rule.cwe,
6968
7214
  excerpt: (lines[index] ?? "").trim().slice(0, 200),