@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/index.js CHANGED
@@ -9939,7 +9939,11 @@ var NODE_RULES = [
9939
9939
  severity: "medium",
9940
9940
  languages: ["javascript", "typescript"],
9941
9941
  pattern: /\bBuffer\s*\.\s*allocUnsafe(?:Slow)?\s*\(|\bnew\s+Buffer\s*\(\s*(?![`'"])[a-zA-Z_$0-9]/,
9942
- inherent: true
9942
+ inherent: true,
9943
+ // Filling the buffer yourself is the whole reason to call `allocUnsafe`,
9944
+ // so reporting every call reports correct code. What is left reported is
9945
+ // an allocation whose bytes are never written before it escapes.
9946
+ filledBeforeUseGuard: true
9943
9947
  },
9944
9948
  {
9945
9949
  id: "js-oversized-request-body-limit",
@@ -10049,8 +10053,19 @@ var CODE_RULES = [
10049
10053
  // The tail is what distinguishes assembly from parameterisation. A bound
10050
10054
  // query leaves a comma after the closing quote (`"… = $1", [id]`) and
10051
10055
  // matches none of these.
10056
+ //
10057
+ // The clause alternative anchors the keyword to the *start* of the
10058
+ // concatenated fragment, because that is where a clause being appended
10059
+ // actually sits: `sql + "WHERE id = " + id`, `sql + " ORDER BY " + col`.
10060
+ //
10061
+ // Allowing it anywhere in the fragment made the rule read English. `WHERE`
10062
+ // and `SET` are ordinary words, and without a leading `\b` they were not
10063
+ // even required to be whole ones — "any`where`" and "sub`set`" both
10064
+ // matched. A help string reading "lists them anywhere" was reported as
10065
+ // critical SQL injection, which is the kind of finding that teaches a team
10066
+ // the scanner is not worth reading.
10052
10067
  pattern: new RegExp(
10053
- `${SQL_STRING}\\s*\\+|${SQL_STRING}\\s*%\\s*[\\w(]|${SQL_STRING}\\s*\\.\\s*format\\s*\\(|\\+\\s*(?:"[^"\\n]*|'[^'\\n]*)(?:WHERE|ORDER\\s+BY|VALUES|SET)\\b`,
10068
+ `${SQL_STRING}\\s*\\+|${SQL_STRING}\\s*%\\s*[\\w(]|${SQL_STRING}\\s*\\.\\s*format\\s*\\(|\\+\\s*(?:"|')\\s*\\b(?:WHERE|ORDER\\s+BY|VALUES|SET)\\b`,
10054
10069
  "i"
10055
10070
  )
10056
10071
  },
@@ -10063,7 +10078,26 @@ var CODE_RULES = [
10063
10078
  pattern: new RegExp(
10064
10079
  `\`[^\`\\n]*(?:${SQL_KEYWORDS})\\b[^\`\\n]*\\$\\{|\\bf"[^"\\n]*(?:${SQL_KEYWORDS})\\b[^"\\n]*\\{|\\bf'[^'\\n]*(?:${SQL_KEYWORDS})\\b[^'\\n]*\\{`,
10065
10080
  "i"
10066
- )
10081
+ ),
10082
+ /**
10083
+ * Interpolating a column list is not interpolating a value.
10084
+ *
10085
+ * const COLS = `tld, user_id, owner_email, price_usd`;
10086
+ * get(`SELECT ${COLS} FROM moshpit_tlds WHERE tld = ?`, [tld]);
10087
+ *
10088
+ * That query *is* parameterised: every value the caller supplies rides a
10089
+ * `?`, and the only thing spliced into the text is a constant written a
10090
+ * few lines up. Naming the column list once instead of repeating it in
10091
+ * fourteen queries is ordinary hygiene, and it is the shape this rule met
10092
+ * most often in practice — one repository produced eighteen findings this
10093
+ * way and not one of them could be injected into.
10094
+ *
10095
+ * The guard resolves the interpolations rather than trusting the shape, so
10096
+ * the moment a query mixes a constant with anything else —
10097
+ * `` `SELECT ${COLS} FROM t WHERE id = ${req.query.id}` `` — it is reported
10098
+ * again. `interpolationsAreConstant` requires *every* `${…}` to resolve.
10099
+ */
10100
+ constantInterpolationGuard: true
10067
10101
  },
10068
10102
  {
10069
10103
  id: "sql-format-call",
@@ -10113,7 +10147,22 @@ var CODE_RULES = [
10113
10147
  cwe: "CWE-78",
10114
10148
  severity: "critical",
10115
10149
  languages: ["go"],
10116
- pattern: /\bexec\.Command(?:Context)?\s*\(\s*(?:ctx\s*,\s*)?"(?:\/bin\/)?(?:sh|bash|zsh|cmd|powershell)"\s*,\s*"(?:-c|\/c)"/
10150
+ pattern: /\bexec\.Command(?:Context)?\s*\(\s*(?:ctx\s*,\s*)?"(?:\/bin\/)?(?:sh|bash|zsh|cmd|powershell)"\s*,\s*"(?:-c|\/c)"/,
10151
+ // A call whose whole argv is string literals cannot be injected into.
10152
+ // `exec.Command("cmd", "/c", "ver")` reads the Windows version; there is no
10153
+ // value in it for an attacker to reach, and the consequence above — that a
10154
+ // shell will interpret metacharacters — describes metacharacters nobody can
10155
+ // supply. gosec's G204 draws the line in the same place, and this was the
10156
+ // single finding a Go project got out of a whole scan before declining the
10157
+ // offer, which is an expensive way to report nothing.
10158
+ //
10159
+ // The guard has to end at the closing paren, so a literal followed by
10160
+ // anything else still reports: `"ls " + dir` leaves a `+` before the `)`,
10161
+ // `fmt.Sprintf(…)` leaves an identifier, and a bare variable leaves a name.
10162
+ // `(?:[^"\\]|\\.)*` rather than `[^"]*` so an escaped quote inside a
10163
+ // literal — `"echo \"hi\""` — does not end the literal early and drop the
10164
+ // guard on a line it should have covered.
10165
+ lineGuard: /\bexec\.Command(?:Context)?\s*\(\s*(?:ctx\s*,\s*)?"(?:\/bin\/)?(?:sh|bash|zsh|cmd|powershell)"\s*,\s*"(?:-c|\/c)"\s*(?:,\s*"(?:[^"\\]|\\.)*")*\s*,?\s*\)/
10117
10166
  },
10118
10167
  {
10119
10168
  id: "rb-backtick-interpolation",
@@ -10182,17 +10231,43 @@ var CODE_RULES = [
10182
10231
  cwe: "CWE-79",
10183
10232
  severity: "high",
10184
10233
  languages: ["javascript", "typescript"],
10185
- pattern: /\bdangerouslySetInnerHTML\s*=|\.\s*(?:innerHTML|outerHTML)\s*=\s*(?!\s*['"`]\s*['"`]\s*;?\s*$)|\bdocument\s*\.\s*write(?:ln)?\s*\(|\.\s*insertAdjacentHTML\s*\(/,
10186
10234
  /**
10235
+ * The assignment alternative carries its own exemption, as a lookahead, so
10236
+ * that it is decided per assignment rather than per line.
10237
+ *
10187
10238
  * A whole-statement assignment of a string with no interpolation and no
10188
- * concatenation carries no data, so it cannot carry attacker data. This
10189
- * was the single largest source of noise: a codebase that builds its UI
10190
- * with innerHTML reports every static heading and spinner as XSS, and a
10191
- * rule that flags 40 safe lines to catch one real one gets switched off.
10239
+ * concatenation carries no data, so it cannot carry attacker data. This was
10240
+ * the single largest source of noise: a codebase that builds its UI with
10241
+ * innerHTML reports every static heading and spinner as XSS, and a rule
10242
+ * that flags 40 safe lines to catch one real one gets switched off.
10243
+ *
10244
+ * Two things this has to get right, and a `lineGuard` could get neither:
10245
+ *
10246
+ * A statement ends at its semicolon, not at the newline. Anchoring to `$`
10247
+ * held the exemption for `el.innerHTML = '';` alone on a line and dropped
10248
+ * it the moment anything followed:
10249
+ *
10250
+ * function hide() { out.hidden = true; out.innerHTML = ''; rendered = null; }
10192
10251
  *
10193
- * Line-scoped on purpose see `lineGuard`.
10252
+ * the same assignment, clearing a node, reported as high-severity XSS
10253
+ * because two neighbours shared its line.
10254
+ *
10255
+ * And an exemption must not become a line-wide amnesty. A guard is tested
10256
+ * against the whole line, so one safe clear would exonerate a real sink
10257
+ * beside it:
10258
+ *
10259
+ * a.innerHTML = ''; b.innerHTML = userInput;
10260
+ *
10261
+ * As a lookahead the regex decides at each `=` it reaches, so the first
10262
+ * assignment is exempt and the second is still reported.
10263
+ *
10264
+ * The whitespace after `=` is matched *inside* the lookahead rather than
10265
+ * before it. Left outside, `\s*` backtracks to zero width, the lookahead
10266
+ * then starts on the space instead of the quote, fails to see a literal,
10267
+ * and the negative lookahead succeeds — reinstating every finding the
10268
+ * exemption was written to remove.
10194
10269
  */
10195
- lineGuard: /(?:innerHTML|outerHTML)\s*=\s*(?:'[^'\\]*'|"[^"\\]*"|`[^`$\\]*`)\s*;?\s*$/
10270
+ pattern: /\bdangerouslySetInnerHTML\s*=|\.\s*(?:innerHTML|outerHTML)\s*=(?!\s*(?:'[^'\\\n]*'|"[^"\\\n]*"|`[^`$\\\n]*`)\s*(?:;|$))|\bdocument\s*\.\s*write(?:ln)?\s*\(|\.\s*insertAdjacentHTML\s*\(/
10196
10271
  },
10197
10272
  {
10198
10273
  id: "java-html-writer-concatenation",
@@ -11130,6 +11205,45 @@ function interpolationsAreConstant(line, fileText) {
11130
11205
  (expr) => /^[A-Za-z_$][\w$]*$/.test(expr) && isConstantString(expr, fileText)
11131
11206
  );
11132
11207
  }
11208
+ var FILL_LOOKAHEAD = 16;
11209
+ var NAME = String.raw`(?<![\w$])([A-Za-z_$][\w$]*)`;
11210
+ var ALLOCATION_BINDING = new RegExp(
11211
+ `${NAME}\\s*=\\s*(?:new\\s+Buffer\\s*\\(|Buffer\\s*\\.\\s*allocUnsafe(?:Slow)?\\s*\\()`
11212
+ );
11213
+ function allocationBinding(line) {
11214
+ return ALLOCATION_BINDING.exec(line)?.[1] ?? null;
11215
+ }
11216
+ var WRITE_SHAPES = [
11217
+ // `src.copy(name, …)` — name is the destination.
11218
+ /\.\s*copy\s*\(\s*([A-Za-z_$][\w$]*)\s*[,)]/g,
11219
+ // `name.fill(…)`, `name.write*(…)`, `name.set(…)`.
11220
+ new RegExp(`${NAME}\\s*\\.\\s*(?:fill|set|write[A-Za-z0-9]*)\\s*\\(`, "g"),
11221
+ // `name[i] = …`, but not `name[i] === …`.
11222
+ new RegExp(`${NAME}\\s*\\[[^\\]\\n]*\\]\\s*=(?!=)`, "g")
11223
+ ];
11224
+ function writesInto(text, name) {
11225
+ for (const shape of WRITE_SHAPES) {
11226
+ shape.lastIndex = 0;
11227
+ for (let found = shape.exec(text); found !== null; found = shape.exec(text)) {
11228
+ if (found[1] === name) return true;
11229
+ }
11230
+ }
11231
+ return false;
11232
+ }
11233
+ function bufferFilledBeforeUse(ctx) {
11234
+ const line = ctx.lines[ctx.index] ?? "";
11235
+ if (/\ballocUnsafe(?:Slow)?\s*\([^)\n]*\)\s*\.\s*fill\s*\(/.test(line)) return true;
11236
+ const name = allocationBinding(line);
11237
+ if (!name) return false;
11238
+ if (writesInto(line.slice(line.indexOf("=") + 1), name)) return true;
11239
+ const last = Math.min(ctx.lines.length - 1, ctx.index + FILL_LOOKAHEAD);
11240
+ for (let i = ctx.index + 1; i <= last; i += 1) {
11241
+ const next = ctx.lines[i] ?? "";
11242
+ if (skippable(next, i, ctx.prose)) continue;
11243
+ if (writesInto(next, name)) return true;
11244
+ }
11245
+ return false;
11246
+ }
11133
11247
  function evaluateRule(rule, ctx) {
11134
11248
  if (rule.languages && !rule.languages.includes(ctx.language)) return null;
11135
11249
  const line = ctx.lines[ctx.index] ?? "";
@@ -11148,6 +11262,7 @@ function evaluateRule(rule, ctx) {
11148
11262
  if (rule.constantInterpolationGuard && interpolationsAreConstant(line, fileTextOf(ctx.lines))) {
11149
11263
  return null;
11150
11264
  }
11265
+ if (rule.filledBeforeUseGuard && bufferFilledBeforeUse(ctx)) return null;
11151
11266
  const guard = rule.guard === void 0 ? GENERIC_GUARD : rule.guard;
11152
11267
  if (guard && (guard.test(line) || guard.test(context))) return null;
11153
11268
  const untrusted = untrustedPatternFor(ctx.language);
@@ -11792,6 +11907,11 @@ var KNOWN_PLACEHOLDERS = [
11792
11907
  function isKnownPlaceholder(text) {
11793
11908
  return KNOWN_PLACEHOLDERS.some((pattern) => pattern.test(text));
11794
11909
  }
11910
+ function isPlaceholderAttribute(line, value) {
11911
+ const at = line.lastIndexOf(value);
11912
+ if (at === -1) return false;
11913
+ return /(?:^|\s)(?:aria-)?placeholder\s*=\s*[{("'`]*$/i.test(line.slice(0, at));
11914
+ }
11795
11915
  function isVariableReference(value) {
11796
11916
  const trimmed = value.trim();
11797
11917
  const braced = /^\$\{\s*([A-Za-z_][\w.]*)\s*(?::?[-=+?]([\s\S]*))?\}$/.exec(trimmed);
@@ -11834,10 +11954,13 @@ function words(text) {
11834
11954
  function isTestFixtureValue(line, value) {
11835
11955
  const valueWords = words(value);
11836
11956
  if (valueWords.some((word) => FIXTURE_STEMS.some((stem) => word.startsWith(stem)))) return true;
11957
+ return describesItsOwnKey(line, value);
11958
+ }
11959
+ function describesItsOwnKey(line, value) {
11837
11960
  if (value.length > 48) return false;
11838
11961
  const valueAt = line.lastIndexOf(value);
11839
11962
  const key = valueAt === -1 ? line : line.slice(0, valueAt);
11840
- const flattened = valueWords.join("");
11963
+ const flattened = words(value).join("");
11841
11964
  return words(key).filter((word) => word.length >= 4 && !KEY_NOISE.has(word)).some((word) => flattened.includes(word));
11842
11965
  }
11843
11966
  function redactSecret(line) {
@@ -12017,9 +12140,9 @@ function languageOfShebang(firstLine) {
12017
12140
  }
12018
12141
  var SUPPRESS_NEXT = /threatcrush-disable-next-line(?:\s+([\w-]+))?/;
12019
12142
  var SUPPRESS_LINE = /threatcrush-disable-line(?:\s+([\w-]+))?/;
12020
- var FOREIGN_CREDENTIAL = /\b(?:nolint:[\w,]*gosec|nosec)\b[^\n]*\bG101\b|\bG101\b[^\n]*\b(?:nolint:[\w,]*gosec|nosec)\b/;
12021
- function foreignCredentialMark(line) {
12022
- return FOREIGN_CREDENTIAL.test(line);
12143
+ var FOREIGN_SECURITY = /\b(?:nolint:[\w,]*gosec|nosec)\b/;
12144
+ function foreignSecurityMark(line) {
12145
+ return FOREIGN_SECURITY.test(line);
12023
12146
  }
12024
12147
  function collectSuppressions(lines) {
12025
12148
  const byLine = /* @__PURE__ */ new Map();
@@ -12045,13 +12168,42 @@ function isSuppressed(suppressions, index, ruleId) {
12045
12168
  }
12046
12169
  function isTestPath(relativePath) {
12047
12170
  const p = relativePath.replace(/\\/g, "/");
12048
- 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);
12171
+ 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);
12049
12172
  }
12173
+ function isDocPath(relativePath) {
12174
+ const p = relativePath.replace(/\\/g, "/");
12175
+ return /(?:^|\/)(?:docs?|examples?|samples?)\//i.test(p) || /\.(?:md|mdx|markdown|rst|adoc)$/i.test(p);
12176
+ }
12177
+ var SOFTENING_ORDER = [
12178
+ "suppressed",
12179
+ "placeholder",
12180
+ "self-describing",
12181
+ "test",
12182
+ "docs"
12183
+ ];
12184
+ function softening(reasons) {
12185
+ return SOFTENING_ORDER.find((reason) => reasons[reason]) ?? null;
12186
+ }
12187
+ var SECRET_SOFTENING = {
12188
+ test: "in a test file \u2014 usually a fixture, still worth confirming it is not a live credential",
12189
+ docs: "in documentation \u2014 usually an illustrative example, still worth confirming it is not a live credential",
12190
+ suppressed: "on a line already marked as a false positive for another linter's security rule",
12191
+ placeholder: "in example text an empty input field shows, not in data",
12192
+ "self-describing": "in a value that repeats the name of the field holding it \u2014 usually a description of a credential rather than one"
12193
+ };
12194
+ var CODE_SOFTENING = {
12195
+ test: "in a test file, where the construct is ordinary",
12196
+ docs: "in documentation or example code, which nothing runs",
12197
+ suppressed: "on a line another linter's security suppression already covers",
12198
+ placeholder: "in example text rather than in data",
12199
+ "self-describing": "in a value that describes itself"
12200
+ };
12050
12201
  function scanText(relativePath, text, language = languageOf(relativePath)) {
12051
12202
  const findings = [];
12052
12203
  const lines = text.split("\n");
12053
12204
  const suppressions = collectSuppressions(lines);
12054
12205
  const inTests = isTestPath(relativePath);
12206
+ const inDocs = isDocPath(relativePath);
12055
12207
  lines.forEach((line, index) => {
12056
12208
  for (const rule of SECRET_RULES) {
12057
12209
  const match = rule.pattern.exec(line);
@@ -12061,18 +12213,23 @@ function scanText(relativePath, text, language = languageOf(relativePath)) {
12061
12213
  const value = match[1] ?? match[0];
12062
12214
  if (isVariableReference(value)) continue;
12063
12215
  if (inTests && rule.keywordShaped && isTestFixtureValue(line, value)) continue;
12064
- const marked = foreignCredentialMark(line);
12216
+ const soft = softening({
12217
+ test: inTests,
12218
+ suppressed: foreignSecurityMark(line),
12219
+ placeholder: isPlaceholderAttribute(line, value),
12220
+ "self-describing": rule.keywordShaped === true && describesItsOwnKey(line, value)
12221
+ });
12065
12222
  findings.push({
12066
12223
  ruleId: rule.id,
12067
12224
  title: rule.name,
12068
12225
  file: relativePath,
12069
12226
  line: index + 1,
12070
- // Reported but not blocking in tests see isTestPath. The same goes
12071
- // for a line another linter's credential rule was already told about.
12072
- severity: inTests || marked ? "low" : rule.severity,
12227
+ // Reported but not blocking wherever context weakens the claim see
12228
+ // `softening`. Never dropped: the count is the same either way.
12229
+ severity: soft ? "low" : rule.severity,
12073
12230
  // A matched credential format is the finding, not a proxy for one.
12074
12231
  confidence: "evidence",
12075
- 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`,
12232
+ message: soft ? `Possible ${rule.name} detected ${SECRET_SOFTENING[soft]}` : `Possible ${rule.name} detected`,
12076
12233
  consequence: rule.consequence,
12077
12234
  cwe: rule.cwe,
12078
12235
  excerpt: redactSecret(line.trim()).slice(0, 200),
@@ -12087,14 +12244,19 @@ function scanText(relativePath, text, language = languageOf(relativePath)) {
12087
12244
  if (isSuppressed(suppressions, index, rule.id)) continue;
12088
12245
  const match = evaluateRule(rule, { lines, index, language, prose });
12089
12246
  if (!match) continue;
12247
+ const soft = softening({
12248
+ test: inTests,
12249
+ docs: inDocs,
12250
+ suppressed: foreignSecurityMark(lines[index] ?? "")
12251
+ });
12090
12252
  findings.push({
12091
12253
  ruleId: rule.id,
12092
12254
  title: rule.title,
12093
12255
  file: relativePath,
12094
12256
  line: index + 1,
12095
- severity: match.severity,
12257
+ severity: soft ? "low" : match.severity,
12096
12258
  confidence: match.confidence,
12097
- message: `${rule.title} (${rule.cwe})`,
12259
+ message: soft ? `${rule.title} (${rule.cwe}) \u2014 ${CODE_SOFTENING[soft]}` : `${rule.title} (${rule.cwe})`,
12098
12260
  consequence: rule.consequence,
12099
12261
  cwe: rule.cwe,
12100
12262
  excerpt: (lines[index] ?? "").trim().slice(0, 200),
@@ -12105,14 +12267,19 @@ function scanText(relativePath, text, language = languageOf(relativePath)) {
12105
12267
  for (const match of evaluateTemplateRules(extensionOf(relativePath), lines)) {
12106
12268
  const index = match.line - 1;
12107
12269
  if (isSuppressed(suppressions, index, match.rule.id)) continue;
12270
+ const soft = softening({
12271
+ test: inTests,
12272
+ docs: inDocs,
12273
+ suppressed: foreignSecurityMark(lines[index] ?? "")
12274
+ });
12108
12275
  findings.push({
12109
12276
  ruleId: match.rule.id,
12110
12277
  title: match.rule.title,
12111
12278
  file: relativePath,
12112
12279
  line: match.line,
12113
- severity: match.severity,
12280
+ severity: soft ? "low" : match.severity,
12114
12281
  confidence: "pattern",
12115
- message: `${match.rule.title} (${match.rule.cwe})`,
12282
+ message: soft ? `${match.rule.title} (${match.rule.cwe}) \u2014 ${CODE_SOFTENING[soft]}` : `${match.rule.title} (${match.rule.cwe})`,
12116
12283
  consequence: match.rule.consequence,
12117
12284
  cwe: match.rule.cwe,
12118
12285
  excerpt: (lines[index] ?? "").trim().slice(0, 200),