@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 +286 -40
- package/dist/daemon.js.map +1 -1
- package/dist/index.js +5202 -743
- package/dist/index.js.map +1 -1
- package/package.json +3 -9
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*(?:"
|
|
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
|
-
*
|
|
5040
|
-
*
|
|
5041
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
5334
|
-
//
|
|
5335
|
-
//
|
|
5336
|
-
|
|
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
|
-
//
|
|
5399
|
-
//
|
|
5400
|
-
|
|
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 =
|
|
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
|
|
6871
|
-
function
|
|
6872
|
-
return
|
|
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
|
|
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
|
|
6921
|
-
//
|
|
6922
|
-
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:
|
|
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),
|