@profullstack/threatcrush 0.10.0 → 0.11.1
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 +641 -11
- package/dist/daemon.js.map +1 -1
- package/dist/index.js +646 -11
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/daemon.js
CHANGED
|
@@ -4310,6 +4310,347 @@ function workerId() {
|
|
|
4310
4310
|
return `${import_node_os4.default.hostname()}/${process.pid}`;
|
|
4311
4311
|
}
|
|
4312
4312
|
|
|
4313
|
+
// ../../packages/scan/src/node-rules.ts
|
|
4314
|
+
var PATH_CONTAINMENT_GUARD = /\bstartsWith\s*\(|\brelative\s*\(|\bnormalize\s*\(|\brealpath\b|\bisInside\b|\bwithin\s*\(|\bsanitiz\w*\b/i;
|
|
4315
|
+
var ARCHIVE_LIBRARY = /\b(?:adm-zip|unzipper|yauzl|node-stream-zip|extract-zip|decompress|tar-stream|tar-fs)\b|require\s*\(\s*['"]tar['"]|from\s+['"]tar['"]/;
|
|
4316
|
+
var HEADLESS_BROWSER = /\b(?:puppeteer|playwright|phantom|phantomjs|wkhtmltopdf|wkhtmltoimage|chrome-aws-lambda|html-pdf)\b/;
|
|
4317
|
+
var ORIGIN_ALLOWLIST_GUARD = /\b(?:includes|indexOf|has|test|find|some)\s*\(/;
|
|
4318
|
+
var FUNCTION_DESERIALIZER = /\b(?:node-serialize|serialize-to-js|funcster|cryo)\b/;
|
|
4319
|
+
var CONCATENATED_STRING = String.raw`(?:"[^"\n]*"|'[^'\n]*')\s*\+`;
|
|
4320
|
+
var INTERPOLATED_TEMPLATE = "`[^`\\n]*\\$\\{";
|
|
4321
|
+
var NODE_RULES = [
|
|
4322
|
+
// ── Code execution ───────────────────────────────────────────────────────
|
|
4323
|
+
{
|
|
4324
|
+
id: "js-vm-untrusted-execution",
|
|
4325
|
+
title: "script compiled and run by the `vm` module",
|
|
4326
|
+
consequence: "Node\u2019s `vm` is not a security boundary \u2014 it isolates globals, not the process. Code reaching it can walk back out through any object it is handed and runs with the server\u2019s full privileges.",
|
|
4327
|
+
cwe: "CWE-94",
|
|
4328
|
+
severity: "critical",
|
|
4329
|
+
languages: ["javascript", "typescript"],
|
|
4330
|
+
pattern: /\bvm\s*\.\s*(?:runInNewContext|runInThisContext|runInContext|compileFunction)\s*\(|\bnew\s+vm\s*\.\s*Script\s*\(/,
|
|
4331
|
+
// A `vm` call whose source is a build-time constant is a plugin loader, not
|
|
4332
|
+
// a vulnerability. The class only becomes real once the script text can be
|
|
4333
|
+
// influenced, so require that evidence before reporting at full severity.
|
|
4334
|
+
needsContext: true
|
|
4335
|
+
},
|
|
4336
|
+
{
|
|
4337
|
+
id: "js-vm2-sandbox",
|
|
4338
|
+
title: "`vm2` used as a sandbox",
|
|
4339
|
+
consequence: "vm2 was discontinued after a series of escapes that its maintainer judged unfixable by design. Any code it runs should be assumed to run on the host.",
|
|
4340
|
+
cwe: "CWE-1104",
|
|
4341
|
+
severity: "high",
|
|
4342
|
+
languages: ["javascript", "typescript"],
|
|
4343
|
+
pattern: /\bnew\s+(?:NodeVM|VMScript)\s*\(|\bfrom\s+['"]vm2['"]|require\s*\(\s*['"]vm2['"]\s*\)/,
|
|
4344
|
+
// Nothing on the surrounding lines changes the answer: the package itself
|
|
4345
|
+
// is the finding, the same way a broken cipher is.
|
|
4346
|
+
inherent: true
|
|
4347
|
+
},
|
|
4348
|
+
{
|
|
4349
|
+
id: "js-function-deserialization",
|
|
4350
|
+
title: "deserialiser that reconstructs functions",
|
|
4351
|
+
consequence: "These formats encode functions alongside data and invoke them on load, so parsing an attacker\u2019s payload is executing it. No amount of validation after the parse call helps \u2014 the code has already run.",
|
|
4352
|
+
cwe: "CWE-502",
|
|
4353
|
+
severity: "critical",
|
|
4354
|
+
languages: ["javascript", "typescript"],
|
|
4355
|
+
pattern: /\b(?:unserialize|deepDeserialize|deserialize)\s*\(/,
|
|
4356
|
+
// Without this the rule fires on every project that happens to own a
|
|
4357
|
+
// function called `deserialize`, which is most of them. The import is what
|
|
4358
|
+
// makes the call the dangerous one.
|
|
4359
|
+
fileRequires: FUNCTION_DESERIALIZER,
|
|
4360
|
+
// The parse *is* the execution, so nearby input cannot make it worse and
|
|
4361
|
+
// its absence cannot make it safe. Nobody round-trips a constant through
|
|
4362
|
+
// these libraries.
|
|
4363
|
+
inherent: true
|
|
4364
|
+
},
|
|
4365
|
+
{
|
|
4366
|
+
id: "js-template-injection",
|
|
4367
|
+
title: "template compiled from a non-constant source",
|
|
4368
|
+
consequence: "Template languages are programming languages. A user-supplied template body is remote code execution, not cross-site scripting \u2014 the expression runs on the server before any output is escaped.",
|
|
4369
|
+
cwe: "CWE-1336",
|
|
4370
|
+
severity: "critical",
|
|
4371
|
+
languages: ["javascript", "typescript"],
|
|
4372
|
+
pattern: /\b(?:handlebars|Handlebars|hbs|ejs|pug|jade|nunjucks|eta|twig|dot|doT|liquid|mustache)\s*\.\s*(?:compile|compileFile|render|renderString)\s*\(\s*(?:`[^`\n]*\$\{|[a-zA-Z_$][\w$.]*\s*[,)]|[a-zA-Z_$][\w$.]*\s*\+)/,
|
|
4373
|
+
// Rendering a template held in a variable is the normal case — it was read
|
|
4374
|
+
// from a file at boot. Only the version where request data reaches the
|
|
4375
|
+
// template *body* is this class.
|
|
4376
|
+
needsContext: true
|
|
4377
|
+
},
|
|
4378
|
+
{
|
|
4379
|
+
id: "js-shelljs-command-execution",
|
|
4380
|
+
title: "shelljs command assembled from a string",
|
|
4381
|
+
consequence: "`shell.exec` runs its argument through a shell, so a `;` or backtick in an interpolated value runs as the server user.",
|
|
4382
|
+
cwe: "CWE-78",
|
|
4383
|
+
severity: "critical",
|
|
4384
|
+
languages: ["javascript", "typescript"],
|
|
4385
|
+
pattern: new RegExp(
|
|
4386
|
+
`\\b(?:shell|shelljs|sh)\\s*\\.\\s*exec\\s*\\(\\s*(?:${INTERPOLATED_TEMPLATE}|${CONCATENATED_STRING}|[a-zA-Z_$][\\w$]*\\s*\\+)`
|
|
4387
|
+
),
|
|
4388
|
+
fileRequires: /\bshelljs\b/
|
|
4389
|
+
},
|
|
4390
|
+
// ── Injection ────────────────────────────────────────────────────────────
|
|
4391
|
+
{
|
|
4392
|
+
id: "js-nosql-where-expression",
|
|
4393
|
+
title: "MongoDB `$where` built by string assembly",
|
|
4394
|
+
consequence: "`$where` is evaluated as JavaScript by the database server, once per document. An interpolated value can rewrite the predicate to `true` or run a denial-of-service loop inside the database.",
|
|
4395
|
+
cwe: "CWE-943",
|
|
4396
|
+
severity: "critical",
|
|
4397
|
+
languages: ["javascript", "typescript"],
|
|
4398
|
+
pattern: new RegExp(
|
|
4399
|
+
`\\$where\\s*['"]?\\s*:\\s*(?:${INTERPOLATED_TEMPLATE}|${CONCATENATED_STRING})|\\.\\s*\\$where\\s*=\\s*(?:${INTERPOLATED_TEMPLATE}|${CONCATENATED_STRING})`
|
|
4400
|
+
)
|
|
4401
|
+
},
|
|
4402
|
+
{
|
|
4403
|
+
id: "js-xpath-injection",
|
|
4404
|
+
title: "XPath expression built by concatenation",
|
|
4405
|
+
consequence: "A quote in the interpolated value closes the predicate early, so the query selects nodes the caller was never meant to read \u2014 the XML equivalent of `OR 1=1`.",
|
|
4406
|
+
cwe: "CWE-643",
|
|
4407
|
+
severity: "high",
|
|
4408
|
+
languages: ["javascript", "typescript"],
|
|
4409
|
+
// An XPath expression is recognisable by its own syntax — a descendant
|
|
4410
|
+
// axis or an attribute predicate. Matching on the *call* name alone would
|
|
4411
|
+
// flag every `select(` in the ecosystem. The quoted forms are split per
|
|
4412
|
+
// quote character for the reason given at CONCATENATED_STRING: the
|
|
4413
|
+
// predicate that makes it XPath usually contains the other quote.
|
|
4414
|
+
pattern: new RegExp(
|
|
4415
|
+
`\\b(?:xpath|xpathSelect|select|selectNodes|selectSingleNode|evaluate|find)\\s*\\(\\s*(?:\`[^\`\\n]*(?:\\/\\/|\\[@)[^\`\\n]*\\$\\{|"[^"\\n]*(?:\\/\\/|\\[@)[^"\\n]*"\\s*\\+|'[^'\\n]*(?:\\/\\/|\\[@)[^'\\n]*'\\s*\\+)`
|
|
4416
|
+
),
|
|
4417
|
+
fileRequires: /\bxpath\b|\bxmldom\b|\blibxmljs\b|\bxpath\.js\b/
|
|
4418
|
+
},
|
|
4419
|
+
{
|
|
4420
|
+
id: "js-regex-from-input",
|
|
4421
|
+
title: "regular expression compiled from a variable",
|
|
4422
|
+
consequence: "A caller who controls the pattern controls the matcher: they can supply catastrophic backtracking to hang the event loop, or a permissive pattern that defeats whatever the regex was validating.",
|
|
4423
|
+
cwe: "CWE-1333",
|
|
4424
|
+
severity: "medium",
|
|
4425
|
+
languages: ["javascript", "typescript"],
|
|
4426
|
+
pattern: new RegExp(
|
|
4427
|
+
`\\bnew\\s+RegExp\\s*\\(\\s*(?:${INTERPOLATED_TEMPLATE}|${CONCATENATED_STRING}|(?!['"\`/])[a-zA-Z_$][\\w$.]*\\s*[,)])`
|
|
4428
|
+
),
|
|
4429
|
+
needsContext: true
|
|
4430
|
+
},
|
|
4431
|
+
// ── XML ──────────────────────────────────────────────────────────────────
|
|
4432
|
+
{
|
|
4433
|
+
id: "js-xml-external-entities",
|
|
4434
|
+
title: "XML parser configured to resolve entities",
|
|
4435
|
+
consequence: "An entity declaration in the document body makes the parser fetch a local file or an internal URL and paste the result into the parsed output \u2014 file disclosure and server-side request forgery from a document upload.",
|
|
4436
|
+
cwe: "CWE-611",
|
|
4437
|
+
severity: "high",
|
|
4438
|
+
languages: ["javascript", "typescript"],
|
|
4439
|
+
// The option name is the finding. Every parser in the ecosystem defaults
|
|
4440
|
+
// these off, so an explicit `true` is a deliberate re-enable.
|
|
4441
|
+
pattern: /\b(?:noent|resolveEntities|expandEntities|externalEntities|resolveExternals)\s*:\s*(?:true|1)\b/,
|
|
4442
|
+
inherent: true
|
|
4443
|
+
},
|
|
4444
|
+
// ── Authentication and tokens ────────────────────────────────────────────
|
|
4445
|
+
{
|
|
4446
|
+
id: "js-jwt-none-algorithm",
|
|
4447
|
+
title: "JWT algorithm set to `none`",
|
|
4448
|
+
consequence: "The `none` algorithm means the signature is not checked. Anyone can mint a token with any claims \u2014 including another user\u2019s id or an admin role \u2014 by base64-encoding a header and a body.",
|
|
4449
|
+
cwe: "CWE-347",
|
|
4450
|
+
severity: "critical",
|
|
4451
|
+
languages: ["javascript", "typescript"],
|
|
4452
|
+
pattern: /\balgorithms?\s*:\s*\[?\s*['"]none['"]/i,
|
|
4453
|
+
inherent: true
|
|
4454
|
+
},
|
|
4455
|
+
// ── Cryptography ─────────────────────────────────────────────────────────
|
|
4456
|
+
{
|
|
4457
|
+
id: "js-broken-cipher-algorithm",
|
|
4458
|
+
title: "broken cipher selected",
|
|
4459
|
+
consequence: "DES, 3DES, RC2, RC4, Blowfish and IDEA are all breakable with commodity hardware or have practical plaintext-recovery attacks. Data encrypted with them should be treated as encoded, not encrypted.",
|
|
4460
|
+
cwe: "CWE-327",
|
|
4461
|
+
severity: "high",
|
|
4462
|
+
languages: ["javascript", "typescript"],
|
|
4463
|
+
pattern: /\bcreate(?:Cipher|Decipher)(?:iv)?\s*\(\s*['"](?:des|des3|des-ede\w*|3des|rc2|rc4|bf|blowfish|cast5?|idea|seed)\b/i,
|
|
4464
|
+
inherent: true
|
|
4465
|
+
},
|
|
4466
|
+
{
|
|
4467
|
+
id: "js-ecb-mode-cipher",
|
|
4468
|
+
title: "block cipher in ECB mode",
|
|
4469
|
+
consequence: "ECB encrypts every block independently, so identical plaintext blocks produce identical ciphertext. Structure in the data survives encryption and blocks can be reordered or replayed without detection.",
|
|
4470
|
+
cwe: "CWE-327",
|
|
4471
|
+
severity: "high",
|
|
4472
|
+
languages: ["javascript", "typescript"],
|
|
4473
|
+
pattern: /\bcreate(?:Cipher|Decipher)(?:iv)?\s*\(\s*['"][^'"\n]*-ecb\b/i,
|
|
4474
|
+
inherent: true
|
|
4475
|
+
},
|
|
4476
|
+
{
|
|
4477
|
+
id: "js-legacy-cipher-api",
|
|
4478
|
+
title: "deprecated `createCipher` used",
|
|
4479
|
+
consequence: "`createCipher` derives the key from a passphrase with a single unsalted MD5 pass and uses a fixed all-zero IV, so the same passphrase always produces the same keystream. It was removed in Node 22.",
|
|
4480
|
+
cwe: "CWE-327",
|
|
4481
|
+
severity: "high",
|
|
4482
|
+
languages: ["javascript", "typescript"],
|
|
4483
|
+
// `createCipheriv` is the correct API and shares the prefix, so the
|
|
4484
|
+
// negative look-ahead is what separates the finding from the fix.
|
|
4485
|
+
pattern: /\bcrypto\s*\.\s*create(?:Cipher|Decipher)\s*\(/,
|
|
4486
|
+
lineGuard: /create(?:Cipher|Decipher)iv\s*\(/,
|
|
4487
|
+
inherent: true
|
|
4488
|
+
},
|
|
4489
|
+
// ── Cross-site scripting ─────────────────────────────────────────────────
|
|
4490
|
+
{
|
|
4491
|
+
id: "js-template-autoescape-disabled",
|
|
4492
|
+
title: "template auto-escaping turned off",
|
|
4493
|
+
consequence: "Auto-escaping is the control that makes a template engine safe by default. Disabling it globally means every interpolation in every template becomes an injection point, including ones written later by someone who assumed the default.",
|
|
4494
|
+
cwe: "CWE-79",
|
|
4495
|
+
severity: "high",
|
|
4496
|
+
languages: ["javascript", "typescript"],
|
|
4497
|
+
pattern: /\bautoescape\s*:\s*false\b|\bescape\s*:\s*false\b|\bnoEscape\s*:\s*true\b/,
|
|
4498
|
+
inherent: true
|
|
4499
|
+
},
|
|
4500
|
+
{
|
|
4501
|
+
id: "js-serialize-javascript-unsafe",
|
|
4502
|
+
title: "`serialize-javascript` in unsafe mode",
|
|
4503
|
+
consequence: "The `unsafe` flag turns off escaping of HTML-significant characters in the output. Embedding the result in a `<script>` block lets a string value close the tag and start a new one.",
|
|
4504
|
+
cwe: "CWE-79",
|
|
4505
|
+
severity: "high",
|
|
4506
|
+
languages: ["javascript", "typescript"],
|
|
4507
|
+
pattern: /\bunsafe\s*:\s*true\b/,
|
|
4508
|
+
fileRequires: /\bserialize-javascript\b/,
|
|
4509
|
+
inherent: true
|
|
4510
|
+
},
|
|
4511
|
+
{
|
|
4512
|
+
id: "js-cors-origin-reflected",
|
|
4513
|
+
title: "CORS origin reflected from the request",
|
|
4514
|
+
consequence: "Echoing the caller\u2019s `Origin` header back as `Access-Control-Allow-Origin` allows every site, while looking like an allow-list. Combined with credentials, any page the victim visits can read their authenticated responses.",
|
|
4515
|
+
cwe: "CWE-942",
|
|
4516
|
+
severity: "high",
|
|
4517
|
+
languages: ["javascript", "typescript"],
|
|
4518
|
+
// Both spellings of the same mistake: writing the header directly, and
|
|
4519
|
+
// handing the request's origin to a CORS middleware's `origin` option.
|
|
4520
|
+
//
|
|
4521
|
+
// An earlier draft also matched a bare variable — `setHeader('…', origin)`
|
|
4522
|
+
// — which is wrong. A variable holding a *validated* origin is exactly
|
|
4523
|
+
// what the correct implementation looks like, and the rule cannot tell the
|
|
4524
|
+
// two apart. Only the visible read from the request qualifies.
|
|
4525
|
+
pattern: /\bAccess-Control-Allow-Origin['"]\s*,\s*(?:req|request|ctx)\s*\.|\borigin\s*:\s*(?:req|request|ctx)\s*\./,
|
|
4526
|
+
// Reflecting the header is the defect whatever surrounds it; there is no
|
|
4527
|
+
// arrangement of nearby lines that makes an echoed origin safe — except a
|
|
4528
|
+
// membership test on the value, which is what the guard looks for.
|
|
4529
|
+
inherent: true,
|
|
4530
|
+
guard: ORIGIN_ALLOWLIST_GUARD
|
|
4531
|
+
},
|
|
4532
|
+
// ── Server-side request forgery ──────────────────────────────────────────
|
|
4533
|
+
{
|
|
4534
|
+
id: "js-headless-browser-navigation",
|
|
4535
|
+
title: "headless browser sent to a non-constant URL",
|
|
4536
|
+
consequence: "The browser runs on the server, inside the private network. A controlled URL reaches the cloud metadata endpoint, localhost admin panels and internal services \u2014 and `file://` reads the disk.",
|
|
4537
|
+
cwe: "CWE-918",
|
|
4538
|
+
severity: "high",
|
|
4539
|
+
languages: ["javascript", "typescript"],
|
|
4540
|
+
pattern: /\.\s*(?:goto|setContent|navigate)\s*\(\s*(?:`[^`\n]*\$\{|(?!['"`])[a-zA-Z_$][\w$.]*\s*[,)])/,
|
|
4541
|
+
fileRequires: HEADLESS_BROWSER,
|
|
4542
|
+
needsContext: true
|
|
4543
|
+
},
|
|
4544
|
+
// ── Path handling ────────────────────────────────────────────────────────
|
|
4545
|
+
{
|
|
4546
|
+
id: "js-archive-entry-path",
|
|
4547
|
+
title: "archive entry written to a path built from its own name",
|
|
4548
|
+
consequence: "An entry named `../../etc/cron.d/x` escapes the extraction directory when its name is joined to the destination. Overwriting a file outside the target \u2014 a systemd unit, an SSH key, a deployed script \u2014 is code execution on the next run.",
|
|
4549
|
+
cwe: "CWE-22",
|
|
4550
|
+
severity: "high",
|
|
4551
|
+
languages: ["javascript", "typescript"],
|
|
4552
|
+
pattern: /\b(?:join|resolve)\s*\(\s*[^,\n)]+,\s*[a-zA-Z_$][\w$]*\s*\.\s*(?:entryName|fileName|filename|name|path)\b/,
|
|
4553
|
+
fileRequires: ARCHIVE_LIBRARY,
|
|
4554
|
+
guard: PATH_CONTAINMENT_GUARD,
|
|
4555
|
+
// The containment check comes *after* the join — you build the path, then
|
|
4556
|
+
// verify it stayed inside. A backwards-only window would miss every
|
|
4557
|
+
// correct implementation and report the safe code alongside the unsafe.
|
|
4558
|
+
guardForward: 3
|
|
4559
|
+
},
|
|
4560
|
+
// ── Electron ─────────────────────────────────────────────────────────────
|
|
4561
|
+
{
|
|
4562
|
+
id: "js-electron-node-integration",
|
|
4563
|
+
title: "Electron renderer given Node access",
|
|
4564
|
+
consequence: "With node integration on \u2014 or context isolation off \u2014 any script that reaches the page reaches `require`. A single XSS in rendered content becomes `child_process.exec` on the user\u2019s machine.",
|
|
4565
|
+
cwe: "CWE-1188",
|
|
4566
|
+
severity: "high",
|
|
4567
|
+
languages: ["javascript", "typescript"],
|
|
4568
|
+
pattern: /\bnodeIntegration(?:InWorker|InSubFrames)?\s*:\s*true\b|\bcontextIsolation\s*:\s*false\b|\benableRemoteModule\s*:\s*true\b|\bsandbox\s*:\s*false\b/,
|
|
4569
|
+
inherent: true
|
|
4570
|
+
},
|
|
4571
|
+
{
|
|
4572
|
+
id: "js-electron-web-security-disabled",
|
|
4573
|
+
title: "Electron web security disabled",
|
|
4574
|
+
consequence: "Turning off `webSecurity` drops the same-origin policy for the window, so remote content can read local files and every other origin the app has loaded.",
|
|
4575
|
+
cwe: "CWE-1173",
|
|
4576
|
+
severity: "high",
|
|
4577
|
+
languages: ["javascript", "typescript"],
|
|
4578
|
+
pattern: /\bwebSecurity\s*:\s*false\b|\ballowRunningInsecureContent\s*:\s*true\b|\bwebviewTag\s*:\s*true\b/,
|
|
4579
|
+
inherent: true
|
|
4580
|
+
},
|
|
4581
|
+
{
|
|
4582
|
+
id: "js-electron-open-external",
|
|
4583
|
+
title: "Electron `openExternal` with a non-constant URL",
|
|
4584
|
+
consequence: "`shell.openExternal` hands the string to the operating system\u2019s handler. A `file://` path executes a local binary and, on Windows, an SMB path executes a remote one.",
|
|
4585
|
+
cwe: "CWE-749",
|
|
4586
|
+
severity: "high",
|
|
4587
|
+
languages: ["javascript", "typescript"],
|
|
4588
|
+
pattern: /\bshell\s*\.\s*openExternal\s*\(\s*(?:`[^`\n]*\$\{|(?!['"`])[a-zA-Z_$][\w$.]*\s*[,)])/,
|
|
4589
|
+
needsContext: true
|
|
4590
|
+
},
|
|
4591
|
+
// ── Hardening and resource limits ────────────────────────────────────────
|
|
4592
|
+
{
|
|
4593
|
+
id: "js-helmet-protection-disabled",
|
|
4594
|
+
title: "security header explicitly disabled",
|
|
4595
|
+
consequence: "Each of these switches off a browser-side protection that was already on. The header stops being sent, so the defence it enables \u2014 framing, sniffing, referrer leakage, transport downgrade \u2014 is available to an attacker again.",
|
|
4596
|
+
cwe: "CWE-693",
|
|
4597
|
+
severity: "medium",
|
|
4598
|
+
languages: ["javascript", "typescript"],
|
|
4599
|
+
pattern: /\b(?:contentSecurityPolicy|frameguard|hsts|noSniff|xssFilter|hidePoweredBy|referrerPolicy|dnsPrefetchControl|ieNoOpen|permittedCrossDomainPolicies|crossOriginEmbedderPolicy|crossOriginOpenerPolicy|crossOriginResourcePolicy|originAgentCluster)\s*:\s*false\b/,
|
|
4600
|
+
inherent: true
|
|
4601
|
+
},
|
|
4602
|
+
{
|
|
4603
|
+
id: "js-buffer-bounds-check-disabled",
|
|
4604
|
+
title: "buffer bounds checking turned off",
|
|
4605
|
+
consequence: "With `noAssert` the read or write is not range-checked, so an offset past the end of the buffer returns adjacent heap memory or corrupts it instead of throwing.",
|
|
4606
|
+
cwe: "CWE-125",
|
|
4607
|
+
severity: "medium",
|
|
4608
|
+
languages: ["javascript", "typescript"],
|
|
4609
|
+
// The trailing `true` on a Buffer numeric accessor *is* `noAssert` — it is
|
|
4610
|
+
// the last positional parameter of every one of these methods. Anchoring
|
|
4611
|
+
// on the accessor name is what keeps this from matching `, true)` on any
|
|
4612
|
+
// call in the codebase.
|
|
4613
|
+
pattern: /\b(?:read|write)(?:U?Int(?:8|16|32)(?:[BL]E)?|U?Int[BL]E|Float[BL]E|Double[BL]E)\s*\([^)\n]*,\s*true\s*\)|\bnoAssert\s*:\s*true\b/,
|
|
4614
|
+
inherent: true
|
|
4615
|
+
},
|
|
4616
|
+
{
|
|
4617
|
+
id: "js-uninitialized-buffer",
|
|
4618
|
+
title: "buffer allocated without zeroing",
|
|
4619
|
+
consequence: "`allocUnsafe` and the old `new Buffer(size)` hand back whatever was previously in that heap memory \u2014 keys, session tokens, other users\u2019 request bodies. Anything not overwritten before the buffer is sent leaks it.",
|
|
4620
|
+
cwe: "CWE-908",
|
|
4621
|
+
severity: "medium",
|
|
4622
|
+
languages: ["javascript", "typescript"],
|
|
4623
|
+
pattern: /\bBuffer\s*\.\s*allocUnsafe(?:Slow)?\s*\(|\bnew\s+Buffer\s*\(\s*(?![`'"])[a-zA-Z_$0-9]/,
|
|
4624
|
+
inherent: true
|
|
4625
|
+
},
|
|
4626
|
+
{
|
|
4627
|
+
id: "js-oversized-request-body-limit",
|
|
4628
|
+
title: "request body limit raised to a very large value",
|
|
4629
|
+
consequence: "A body limit in the tens of megabytes lets a handful of concurrent requests exhaust memory, and the parse happens before any authentication check the route performs.",
|
|
4630
|
+
cwe: "CWE-400",
|
|
4631
|
+
severity: "medium",
|
|
4632
|
+
languages: ["javascript", "typescript"],
|
|
4633
|
+
// Two or more digits before `mb` — 50mb and up. A limit is a *good* thing;
|
|
4634
|
+
// only an ineffective one is the finding.
|
|
4635
|
+
pattern: /\blimit\s*:\s*['"]\s*(?:[5-9]\d|\d{3,})\s*mb\s*['"]|\blimit\s*:\s*['"]\s*\d+\s*gb\s*['"]/i,
|
|
4636
|
+
inherent: true
|
|
4637
|
+
},
|
|
4638
|
+
// ── Information disclosure ───────────────────────────────────────────────
|
|
4639
|
+
{
|
|
4640
|
+
id: "js-error-detail-returned",
|
|
4641
|
+
title: "error object or stack trace sent to the client",
|
|
4642
|
+
consequence: "A stack trace names absolute paths, package versions and internal function names, and framework errors often carry the failing query or connection string with them. It is a map of the server, handed out on request.",
|
|
4643
|
+
cwe: "CWE-209",
|
|
4644
|
+
severity: "medium",
|
|
4645
|
+
languages: ["javascript", "typescript"],
|
|
4646
|
+
// `res.status(500).send(…)` is the overwhelmingly common spelling, so the
|
|
4647
|
+
// optional `.status(…)` hop is not a nicety — without it the rule misses
|
|
4648
|
+
// nearly every real occurrence.
|
|
4649
|
+
pattern: /\bres\s*\.\s*(?:status\s*\([^)\n]*\)\s*\.\s*)?(?:send|json|end|write)\s*\(\s*(?:[a-zA-Z_$][\w$]*\s*\.\s*stack\b|(?:error|err|e)\s*[,)])|\.\s*(?:send|json)\s*\(\s*\{[^}\n]*\b(?:stack|err|error)\s*:\s*(?:error|err|e)\s*[,}]/,
|
|
4650
|
+
inherent: true
|
|
4651
|
+
}
|
|
4652
|
+
];
|
|
4653
|
+
|
|
4313
4654
|
// ../../packages/scan/src/types.ts
|
|
4314
4655
|
var SEVERITY_ORDER = ["info", "low", "medium", "high", "critical"];
|
|
4315
4656
|
function severityRank(severity) {
|
|
@@ -4356,7 +4697,20 @@ var GENERIC_GUARD = (
|
|
|
4356
4697
|
// The identifier must END at the escaper (with at most a known output-context
|
|
4357
4698
|
// suffix). An earlier, looser form also matched `describe(`, which would have
|
|
4358
4699
|
// silenced findings across every test file in every repository.
|
|
4359
|
-
|
|
4700
|
+
// The `Access-Control-` lookbehind is not a nicety. This regex is
|
|
4701
|
+
// case-insensitive, and `Access-Control-Allow-Origin` contains the word
|
|
4702
|
+
// "Allow" followed by a non-word character — so the CORS header name matched
|
|
4703
|
+
// the allow-list heuristic. Because the guard is tested against an 8-line
|
|
4704
|
+
// *window*, one header line silently disabled every guardable rule near it:
|
|
4705
|
+
// in a four-line Express error handler, the header on one line suppressed
|
|
4706
|
+
// both the `none`-algorithm JWT finding and the returned stack trace below
|
|
4707
|
+
// it. The failure mode is the dangerous kind — not fewer findings, none, and
|
|
4708
|
+
// indistinguishable from clean code.
|
|
4709
|
+
//
|
|
4710
|
+
// Scoped to the header prefix rather than to a bare `allow(?!-)`, because
|
|
4711
|
+
// this guard also runs over `.yml` and `.conf` files, where `allow-list:`
|
|
4712
|
+
// and `allowed-hosts:` are ordinary keys that should still guard.
|
|
4713
|
+
/(?<!Access-Control-)\ballow(?:ed|list|_list|ed_hosts)?\b|\bwhitelist\b|\b\w{0,6}[Ee]sc(?:ape)?(?:[Hh]tml|HTML|[Xx]ml|XML|[Ss]ql|[Aa]ttr|[Jj]s|[Uu]ri|[Uu]rl)?\s*\(|\bhtml_escape\b|\bhtmlspecialchars\s*\(|\bsanitiz\w*\b|\bencoded\b|\brealpath\b|\bcommonpath\b|\bresolve\(\)\.startsWith\b|\bprocess\.env\b|\bos\.environ\b|\bgetenv\b|\bENV\s*\[|setObjectInputFilter|ObjectInputFilter/i
|
|
4360
4714
|
);
|
|
4361
4715
|
var XXE_GUARD = /FEATURE_SECURE_PROCESSING|setExpandEntityReferences|disallow-doctype-decl|external-general-entities|external-parameter-entities|setXIncludeAware\s*\(\s*false/;
|
|
4362
4716
|
var XML_PARSING_FILE = /\b(?:javax\.xml|org\.xml\.sax|org\.w3c\.dom|org\.jdom2?|org\.dom4j|XmlPullParser|DocumentBuilderFactory|DocumentBuilder|SAXParserFactory|SAXParser|XMLInputFactory|XMLReaderFactory|XMLReader|SAXBuilder|SAXReader)\b/;
|
|
@@ -4784,6 +5138,20 @@ var CODE_RULES = [
|
|
|
4784
5138
|
consequence: "Catastrophic backtracking: a crafted input of a few dozen characters pins a CPU core for minutes.",
|
|
4785
5139
|
cwe: "CWE-1333",
|
|
4786
5140
|
severity: "medium",
|
|
5141
|
+
// Scoped, because unscoped this rule reads C as if it were a regex.
|
|
5142
|
+
//
|
|
5143
|
+
// `(void *)*memptr64` — a cast to a pointer type, then a dereference — is
|
|
5144
|
+
// the single most ordinary line in a C file, and it matches the first
|
|
5145
|
+
// alternative exactly: `(`, some text, `*`, `)`, `*`. Every `*(char *)*p`
|
|
5146
|
+
// in a codebase became a ReDoS finding. Caught on inspektor-gadget, where
|
|
5147
|
+
// the rule's one and only hit across 1186 files was a `bpf_probe_read_user`
|
|
5148
|
+
// call in an eBPF C program.
|
|
5149
|
+
//
|
|
5150
|
+
// Scoping is the fix rather than a cleverer pattern: C has no regex
|
|
5151
|
+
// literals, so there is nothing here for the rule to find no matter how
|
|
5152
|
+
// the pattern is written. `other` also covers C++, Rust and Zig, which
|
|
5153
|
+
// share the cast-then-deref spelling.
|
|
5154
|
+
languages: ["javascript", "typescript", "python", "ruby", "go", "java", "php"],
|
|
4787
5155
|
pattern: /\([^)\n]*[+*]\s*\)\s*[+*]|\([^)\n]*\{\d+,\}\s*\)\s*[+*{]/
|
|
4788
5156
|
},
|
|
4789
5157
|
// ── Temporary files ──────────────────────────────────────────────────────
|
|
@@ -5203,9 +5571,13 @@ var CODE_RULES = [
|
|
|
5203
5571
|
severity: "high",
|
|
5204
5572
|
languages: ["python"],
|
|
5205
5573
|
// An f-string whose content is an LDAP filter (`(&…` / `(|…`) with an
|
|
5206
|
-
// interpolation.
|
|
5207
|
-
|
|
5208
|
-
|
|
5574
|
+
// interpolation. One variant per quote so an inner `'` inside a `"`-string
|
|
5575
|
+
// does not end the class early.
|
|
5576
|
+
pattern: /\bf"[^"\n]*\([&|][^"\n]*\{[^}\n]+\}|\bf'[^'\n]*\([&|][^'\n]*\{[^}\n]+\}/,
|
|
5577
|
+
// The guard is an escaper *call*, not the mere presence of `escape` — the
|
|
5578
|
+
// module-level `from ldap.filter import escape_filter_chars` sits in every
|
|
5579
|
+
// window and would otherwise exonerate the unescaped filter too.
|
|
5580
|
+
guard: /\bescape\w*\s*\(/i
|
|
5209
5581
|
},
|
|
5210
5582
|
{
|
|
5211
5583
|
id: "py-xpath-injection",
|
|
@@ -5214,7 +5586,9 @@ var CODE_RULES = [
|
|
|
5214
5586
|
cwe: "CWE-643",
|
|
5215
5587
|
severity: "high",
|
|
5216
5588
|
languages: ["python"],
|
|
5217
|
-
|
|
5589
|
+
// Per-quote, so an inner `'` in a `"`-delimited f-string (`text()='{x}'`)
|
|
5590
|
+
// does not truncate the match before the interpolation.
|
|
5591
|
+
pattern: /\bf"[^"\n]*(?:\/\/|\/\w+\[)[^"\n]*\{[^}\n]+\}|\bf'[^'\n]*(?:\/\/|\/\w+\[)[^'\n]*\{[^}\n]+\}/
|
|
5218
5592
|
},
|
|
5219
5593
|
{
|
|
5220
5594
|
id: "py-fast-password-hash",
|
|
@@ -5284,7 +5658,39 @@ var CODE_RULES = [
|
|
|
5284
5658
|
severity: "high",
|
|
5285
5659
|
languages: ["javascript", "typescript"],
|
|
5286
5660
|
pattern: /\.\s*(?:setHeader|header|set)\s*\(\s*['"`][^'"`\n]+['"`]\s*,\s*(?:req|request|ctx)\s*\.\s*(?:query|body|params|headers)\b/
|
|
5287
|
-
}
|
|
5661
|
+
},
|
|
5662
|
+
{
|
|
5663
|
+
id: "js-nosql-injection",
|
|
5664
|
+
title: "NoSQL query built from request data",
|
|
5665
|
+
consequence: 'A request object passed straight to `find`/`update` lets the caller supply MongoDB operators \u2014 `{"$ne": null}`, `{"$gt": ""}`, `{"$where": "\u2026"}` \u2014 turning a lookup into an authentication bypass or arbitrary query.',
|
|
5666
|
+
cwe: "CWE-943",
|
|
5667
|
+
severity: "high",
|
|
5668
|
+
languages: ["javascript", "typescript"],
|
|
5669
|
+
pattern: /\.\s*(?:find|findOne|findOneAndUpdate|updateOne|updateMany|update|deleteOne|deleteMany|remove|count|countDocuments|aggregate)\s*\(\s*(?:req|request|ctx)\s*\.\s*(?:body|query|params)\b/
|
|
5670
|
+
},
|
|
5671
|
+
{
|
|
5672
|
+
id: "js-host-header-trust",
|
|
5673
|
+
title: "URL built from the Host header",
|
|
5674
|
+
consequence: "The Host header is attacker-controlled. Building a link from it \u2014 a password-reset URL above all \u2014 lets an attacker point the victim\u2019s reset token at a domain they control.",
|
|
5675
|
+
cwe: "CWE-346",
|
|
5676
|
+
severity: "high",
|
|
5677
|
+
languages: ["javascript", "typescript"],
|
|
5678
|
+
pattern: /\$\{[^}\n]*\breq(?:uest)?\s*\.\s*(?:headers\s*\.\s*host|hostname|host)\b|['"`]\s*\+\s*req(?:uest)?\s*\.\s*(?:headers\s*\.\s*host|hostname)\b/
|
|
5679
|
+
},
|
|
5680
|
+
// A recursive-merge prototype-pollution rule (`target[key] = source[key]`
|
|
5681
|
+
// with no `__proto__` guard) was built and dropped. The bare copy-by-key is
|
|
5682
|
+
// the safe allow-listed shape (`updates[field] = body[field]` over an
|
|
5683
|
+
// `allowedFields` list) as often as the vulnerable one; whether it is a sink
|
|
5684
|
+
// depends on where the key comes from and which of endless guard idioms
|
|
5685
|
+
// filters it — a whole-function question this line-oriented engine does not
|
|
5686
|
+
// answer. It flagged legitimate merges in Capacitor and in this repo's own
|
|
5687
|
+
// web app. `js-prototype-pollution` still catches the explicit `__proto__`
|
|
5688
|
+
// literal; the recursive-merge case is left to KNOWN_GAPS.
|
|
5689
|
+
// Node-ecosystem classes — vm2, Electron, JWT, archive extraction, headless
|
|
5690
|
+
// browsers — are kept in their own table because each has to know which
|
|
5691
|
+
// package it is looking at before it can claim anything. Folded in here so
|
|
5692
|
+
// there stays exactly one rule list for every consumer to iterate.
|
|
5693
|
+
...NODE_RULES
|
|
5288
5694
|
];
|
|
5289
5695
|
var COMMENT_PREFIX = /^\s*(?:\/\/|\/\*|\*|#|--|<!--)/;
|
|
5290
5696
|
var DEFINITION_PREFIX = /^\s*(?:(?:export|public|private|protected|static|final|async|abstract)\s+)*(?:def|function|func|class|module|interface|struct|impl|fn|sub)\b/;
|
|
@@ -5359,6 +5765,188 @@ function evaluateRule(rule, ctx) {
|
|
|
5359
5765
|
return { rule, confidence, severity: severityFor(rule.severity, confidence) };
|
|
5360
5766
|
}
|
|
5361
5767
|
|
|
5768
|
+
// ../../packages/scan/src/template-rules.ts
|
|
5769
|
+
var LAYOUT_SLOT = /\{\{\{\s*(?:body|content|outlet|children)\s*\}\}\}/;
|
|
5770
|
+
var XSS_CONSEQUENCE = "The value is written into the page as markup rather than as text, so a `<script>` or an `onerror=` attribute in it executes in the visitor\u2019s session \u2014 with their cookies, and their privileges.";
|
|
5771
|
+
var TEMPLATE_RULES = [
|
|
5772
|
+
{
|
|
5773
|
+
id: "tpl-handlebars-unescaped",
|
|
5774
|
+
title: "Handlebars/Mustache interpolation that skips escaping",
|
|
5775
|
+
consequence: XSS_CONSEQUENCE,
|
|
5776
|
+
cwe: "CWE-79",
|
|
5777
|
+
severity: "high",
|
|
5778
|
+
extensions: [".hbs", ".handlebars", ".hdbs", ".mustache", ".ms"],
|
|
5779
|
+
// Two spellings of the same opt-out: the triple-stache and the `&` prefix.
|
|
5780
|
+
pattern: /\{\{\{(?!\{)[^}]*\}\}\}|\{\{\s*&\s*[^}]+\}\}/,
|
|
5781
|
+
lineGuard: LAYOUT_SLOT
|
|
5782
|
+
},
|
|
5783
|
+
{
|
|
5784
|
+
id: "tpl-vue-v-html",
|
|
5785
|
+
title: "`v-html` binding",
|
|
5786
|
+
consequence: XSS_CONSEQUENCE,
|
|
5787
|
+
cwe: "CWE-79",
|
|
5788
|
+
severity: "high",
|
|
5789
|
+
extensions: [".vue", ".html", ".htm"],
|
|
5790
|
+
pattern: /\bv-html\s*=|\s:inner-html\.prop\s*=/
|
|
5791
|
+
},
|
|
5792
|
+
{
|
|
5793
|
+
id: "tpl-pug-unescaped",
|
|
5794
|
+
title: "Pug/Jade unescaped interpolation",
|
|
5795
|
+
consequence: XSS_CONSEQUENCE,
|
|
5796
|
+
cwe: "CWE-79",
|
|
5797
|
+
severity: "high",
|
|
5798
|
+
extensions: [".pug", ".jade"],
|
|
5799
|
+
// `!{…}` is unescaped interpolation anywhere on the line. `!=` is
|
|
5800
|
+
// unescaped buffered code, but only in the tag position — anchored to the
|
|
5801
|
+
// start of the line and preceded by nothing but tag, class and id
|
|
5802
|
+
// characters, so the `!==` inside `- if (a !== b)` cannot reach it.
|
|
5803
|
+
pattern: /!\{[^}\n]*\}|^\s*[\w.#%-]*!=(?!=)\s*\S/
|
|
5804
|
+
},
|
|
5805
|
+
{
|
|
5806
|
+
id: "tpl-ejs-raw-output",
|
|
5807
|
+
title: "EJS/ECT raw output tag",
|
|
5808
|
+
consequence: XSS_CONSEQUENCE,
|
|
5809
|
+
cwe: "CWE-79",
|
|
5810
|
+
severity: "high",
|
|
5811
|
+
extensions: [".ejs", ".ect"],
|
|
5812
|
+
// In EJS `<%= %>` escapes and `<%-` does not, which is the reverse of
|
|
5813
|
+
// Underscore's convention — see KNOWN_GAPS.
|
|
5814
|
+
pattern: /<%-(?!\s*(?:include|-))/,
|
|
5815
|
+
// `<%- include('partial') %>` splices another template, not user data.
|
|
5816
|
+
lineGuard: /<%-\s*(?:include|partial)\s*\(/
|
|
5817
|
+
},
|
|
5818
|
+
{
|
|
5819
|
+
id: "tpl-dust-escape-filter-off",
|
|
5820
|
+
title: "Dust reference with escaping suppressed",
|
|
5821
|
+
consequence: XSS_CONSEQUENCE,
|
|
5822
|
+
cwe: "CWE-79",
|
|
5823
|
+
severity: "high",
|
|
5824
|
+
extensions: [".dust", ".tl"],
|
|
5825
|
+
// The `|s` filter means "suppress the default HTML escape".
|
|
5826
|
+
pattern: /\{[^{}\n]+\|\s*s\s*\}/
|
|
5827
|
+
},
|
|
5828
|
+
{
|
|
5829
|
+
id: "tpl-jinja-safe-filter",
|
|
5830
|
+
title: "Nunjucks/Twig/Jinja `safe` filter or autoescape block off",
|
|
5831
|
+
consequence: XSS_CONSEQUENCE,
|
|
5832
|
+
cwe: "CWE-79",
|
|
5833
|
+
severity: "high",
|
|
5834
|
+
extensions: [".njk", ".nunjucks", ".twig", ".jinja", ".jinja2", ".j2"],
|
|
5835
|
+
pattern: /\|\s*(?:safe|raw)\s*(?:\}\}|\|)|\{%\s*autoescape\s+(?:false|off)\s*%\}/
|
|
5836
|
+
},
|
|
5837
|
+
{
|
|
5838
|
+
id: "tpl-haml-unescaped",
|
|
5839
|
+
title: "Haml unescaped output",
|
|
5840
|
+
consequence: XSS_CONSEQUENCE,
|
|
5841
|
+
cwe: "CWE-79",
|
|
5842
|
+
severity: "high",
|
|
5843
|
+
extensions: [".haml"],
|
|
5844
|
+
pattern: /^\s*[\w.#%-]*!=(?!=)\s*\S/
|
|
5845
|
+
}
|
|
5846
|
+
];
|
|
5847
|
+
var TEMPLATE_EXTENSIONS = new Set(
|
|
5848
|
+
TEMPLATE_RULES.flatMap((rule) => rule.extensions)
|
|
5849
|
+
);
|
|
5850
|
+
function evaluateTemplateRules(extension, lines) {
|
|
5851
|
+
const ext = extension.toLowerCase();
|
|
5852
|
+
const applicable = TEMPLATE_RULES.filter((rule) => rule.extensions.includes(ext));
|
|
5853
|
+
if (applicable.length === 0) return [];
|
|
5854
|
+
const matches = [];
|
|
5855
|
+
lines.forEach((line, index) => {
|
|
5856
|
+
for (const rule of applicable) {
|
|
5857
|
+
if (!rule.pattern.test(line)) continue;
|
|
5858
|
+
if (rule.lineGuard?.test(line)) continue;
|
|
5859
|
+
matches.push({
|
|
5860
|
+
rule,
|
|
5861
|
+
// A template cannot show that the value is untrusted, so the claim
|
|
5862
|
+
// never rises above `pattern` and the shared cap holds it at medium.
|
|
5863
|
+
severity: severityFor(rule.severity, "pattern"),
|
|
5864
|
+
line: index + 1
|
|
5865
|
+
});
|
|
5866
|
+
}
|
|
5867
|
+
});
|
|
5868
|
+
return matches;
|
|
5869
|
+
}
|
|
5870
|
+
|
|
5871
|
+
// ../../packages/scan/src/controls.ts
|
|
5872
|
+
var SERVER_FRAMEWORK = /\bexpress\s*\(\s*\)|\bnew\s+Koa\s*\(|\bfastify\s*\(|\brequire\s*\(\s*['"](?:express|koa|@hapi\/hapi|restify)['"]\s*\)|\bfrom\s+['"](?:express|koa|@hapi\/hapi|restify)['"]|\bhttp\s*\.\s*createServer\s*\(|\bNestFactory\s*\.\s*create\s*\(/;
|
|
5873
|
+
var SECURITY_CONTROLS = [
|
|
5874
|
+
{
|
|
5875
|
+
id: "control-security-headers-absent",
|
|
5876
|
+
title: "no security header middleware",
|
|
5877
|
+
consequence: "Without them the browser applies no framing protection, sniffs content types, sends full referrers cross-origin and never learns to require HTTPS \u2014 a set of defences that cost one line to enable.",
|
|
5878
|
+
cwe: "CWE-693",
|
|
5879
|
+
severity: "medium",
|
|
5880
|
+
evidence: /\bhelmet\b|\bkoa-helmet\b|\b@fastify\/helmet\b|\blusca\b|Strict-Transport-Security|Content-Security-Policy|X-Frame-Options|X-Content-Type-Options/i
|
|
5881
|
+
},
|
|
5882
|
+
{
|
|
5883
|
+
id: "control-anti-csrf-absent",
|
|
5884
|
+
title: "no cross-site request forgery protection",
|
|
5885
|
+
consequence: "A cookie-authenticated endpoint with no token check can be driven by a form on any other site \u2014 the browser attaches the session automatically, so the victim only has to visit a page.",
|
|
5886
|
+
cwe: "CWE-352",
|
|
5887
|
+
severity: "medium",
|
|
5888
|
+
// SameSite counts. A cookie the browser refuses to send cross-site is not
|
|
5889
|
+
// reachable by the attack this control exists to stop, so a project that
|
|
5890
|
+
// chose that route instead of tokens has the control, not a gap.
|
|
5891
|
+
evidence: /\bcsurf\b|\bcsrf\b|\bxsrf\b|\blusca\b|\b@fastify\/csrf\b|\bdouble-csrf\b|\bsameSite\s*:\s*['"](?:strict|lax)['"]/i
|
|
5892
|
+
},
|
|
5893
|
+
{
|
|
5894
|
+
id: "control-rate-limiting-absent",
|
|
5895
|
+
title: "no request rate limiting",
|
|
5896
|
+
consequence: "Login and password-reset endpoints with no limiter can be tried at the speed of the network \u2014 credential stuffing, token brute force and enumeration all become a matter of waiting.",
|
|
5897
|
+
cwe: "CWE-770",
|
|
5898
|
+
severity: "medium",
|
|
5899
|
+
evidence: /\bexpress-rate-limit\b|\brateLimit\b|\brate-limiter\b|\bratelimit\b|\bexpress-slow-down\b|\bslowDown\b|\bbottleneck\b|\bthrottle\b/i
|
|
5900
|
+
},
|
|
5901
|
+
{
|
|
5902
|
+
id: "control-body-size-limit-absent",
|
|
5903
|
+
title: "no request body size limit",
|
|
5904
|
+
consequence: "The body is parsed into memory before any handler \u2014 and before any authentication check \u2014 so unbounded parsing lets a few concurrent requests exhaust the process.",
|
|
5905
|
+
cwe: "CWE-400",
|
|
5906
|
+
severity: "medium",
|
|
5907
|
+
evidence: /\blimit\s*:\s*['"]?\d+\s*(?:kb|mb|b)\b|\bbodyLimit\b|\bmaxRequestBodySize\b|\bclient_max_body_size\b/i
|
|
5908
|
+
}
|
|
5909
|
+
];
|
|
5910
|
+
var ControlAudit = class {
|
|
5911
|
+
seen = /* @__PURE__ */ new Set();
|
|
5912
|
+
/** Where the server is built. Anchors the findings somewhere meaningful. */
|
|
5913
|
+
serverFile = null;
|
|
5914
|
+
observe(relativePath, text) {
|
|
5915
|
+
if (this.serverFile === null && SERVER_FRAMEWORK.test(text)) {
|
|
5916
|
+
this.serverFile = relativePath;
|
|
5917
|
+
}
|
|
5918
|
+
for (const control of SECURITY_CONTROLS) {
|
|
5919
|
+
if (this.seen.has(control.id)) continue;
|
|
5920
|
+
if (control.evidence.test(text)) this.seen.add(control.id);
|
|
5921
|
+
}
|
|
5922
|
+
}
|
|
5923
|
+
/** Controls with no evidence anywhere. Empty when the tree serves no HTTP. */
|
|
5924
|
+
missing() {
|
|
5925
|
+
if (this.serverFile === null) return [];
|
|
5926
|
+
return SECURITY_CONTROLS.filter((control) => !this.seen.has(control.id));
|
|
5927
|
+
}
|
|
5928
|
+
findings() {
|
|
5929
|
+
const anchor = this.serverFile;
|
|
5930
|
+
if (anchor === null) return [];
|
|
5931
|
+
return this.missing().map((control) => ({
|
|
5932
|
+
ruleId: control.id,
|
|
5933
|
+
title: control.title,
|
|
5934
|
+
file: anchor,
|
|
5935
|
+
line: 1,
|
|
5936
|
+
// `pattern` is doing real work here: it caps the severity, and it says
|
|
5937
|
+
// in the report itself how much the scanner is claiming. An absence is
|
|
5938
|
+
// never `evidence`.
|
|
5939
|
+
severity: severityFor(control.severity, "pattern"),
|
|
5940
|
+
confidence: "pattern",
|
|
5941
|
+
message: `${control.title} \u2014 no evidence of one anywhere in the scanned tree (${control.cwe})`,
|
|
5942
|
+
consequence: control.consequence,
|
|
5943
|
+
cwe: control.cwe,
|
|
5944
|
+
excerpt: "",
|
|
5945
|
+
category: "code"
|
|
5946
|
+
}));
|
|
5947
|
+
}
|
|
5948
|
+
};
|
|
5949
|
+
|
|
5362
5950
|
// ../../packages/scan/src/manifest-rules.ts
|
|
5363
5951
|
var POPULAR_NPM = [
|
|
5364
5952
|
"react",
|
|
@@ -5790,7 +6378,18 @@ var KNOWN_PLACEHOLDERS = [
|
|
|
5790
6378
|
/\bEXAMPLE_?KEY\b/i,
|
|
5791
6379
|
/\bYOUR_[A-Z_]*(?:KEY|TOKEN|SECRET|PASSWORD)\b/,
|
|
5792
6380
|
/\b(?:xxx+|X{4,}|\*{4,}|<[a-z-]+>)\b/,
|
|
5793
|
-
/\bchangeme\b/i
|
|
6381
|
+
/\bchangeme\b/i,
|
|
6382
|
+
// The metasyntactic pair in a connection string, `postgres://user:pass@host`.
|
|
6383
|
+
// This is not the AWS case above and the distinction is the whole reason it
|
|
6384
|
+
// is allowed: `AKIAIOSFODNN7EXAMPLE` is a real credential *format* carrying a
|
|
6385
|
+
// fake value, so it arrives by way of a pasted template. `user:pass` is the
|
|
6386
|
+
// English words sitting where a credential goes, which is how every database
|
|
6387
|
+
// driver writes its DSN in its own README, and nobody pastes that out of a
|
|
6388
|
+
// secret manager.
|
|
6389
|
+
//
|
|
6390
|
+
// Both halves have to be metasyntactic. `root:hunter2@` is not exempt, since
|
|
6391
|
+
// a real password beside a common username is the case this must not swallow.
|
|
6392
|
+
/:\/\/(?:user(?:name)?|admin|root|dbuser|myuser):(?:pass(?:word|wd)?|secret|dbpass|mypassword)@/i
|
|
5794
6393
|
];
|
|
5795
6394
|
function isKnownPlaceholder(text) {
|
|
5796
6395
|
return KNOWN_PLACEHOLDERS.some((pattern) => pattern.test(text));
|
|
@@ -5895,7 +6494,12 @@ var SCAN_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
|
5895
6494
|
".erb",
|
|
5896
6495
|
".ejs",
|
|
5897
6496
|
".vue",
|
|
5898
|
-
".svelte"
|
|
6497
|
+
".svelte",
|
|
6498
|
+
// Template files, so the engine in `template-rules.ts` has something to
|
|
6499
|
+
// read. Sourced from the rules themselves rather than repeated here: an
|
|
6500
|
+
// engine added there becomes scannable without a second edit that could be
|
|
6501
|
+
// forgotten, which is how a rule ends up quietly never firing.
|
|
6502
|
+
...TEMPLATE_EXTENSIONS
|
|
5899
6503
|
]);
|
|
5900
6504
|
var LANGUAGE_BY_EXTENSION = {
|
|
5901
6505
|
".js": "javascript",
|
|
@@ -5967,6 +6571,10 @@ function languageOfShebang(firstLine) {
|
|
|
5967
6571
|
}
|
|
5968
6572
|
var SUPPRESS_NEXT = /threatcrush-disable-next-line(?:\s+([\w-]+))?/;
|
|
5969
6573
|
var SUPPRESS_LINE = /threatcrush-disable-line(?:\s+([\w-]+))?/;
|
|
6574
|
+
var FOREIGN_CREDENTIAL = /\b(?:nolint:[\w,]*gosec|nosec)\b[^\n]*\bG101\b|\bG101\b[^\n]*\b(?:nolint:[\w,]*gosec|nosec)\b/;
|
|
6575
|
+
function foreignCredentialMark(line) {
|
|
6576
|
+
return FOREIGN_CREDENTIAL.test(line);
|
|
6577
|
+
}
|
|
5970
6578
|
function collectSuppressions(lines) {
|
|
5971
6579
|
const byLine = /* @__PURE__ */ new Map();
|
|
5972
6580
|
let count = 0;
|
|
@@ -6004,16 +6612,18 @@ function scanText(relativePath, text, language = languageOf(relativePath)) {
|
|
|
6004
6612
|
if (!match) continue;
|
|
6005
6613
|
if (isKnownPlaceholder(match[0])) continue;
|
|
6006
6614
|
if (isSuppressed(suppressions, index, rule.id)) continue;
|
|
6615
|
+
const marked = foreignCredentialMark(line);
|
|
6007
6616
|
findings.push({
|
|
6008
6617
|
ruleId: rule.id,
|
|
6009
6618
|
title: rule.name,
|
|
6010
6619
|
file: relativePath,
|
|
6011
6620
|
line: index + 1,
|
|
6012
|
-
// Reported but not blocking in tests — see isTestPath.
|
|
6013
|
-
|
|
6621
|
+
// Reported but not blocking in tests — see isTestPath. The same goes
|
|
6622
|
+
// for a line another linter's credential rule was already told about.
|
|
6623
|
+
severity: inTests || marked ? "low" : rule.severity,
|
|
6014
6624
|
// A matched credential format is the finding, not a proxy for one.
|
|
6015
6625
|
confidence: "evidence",
|
|
6016
|
-
message: inTests ? `Possible ${rule.name} detected in a test file \u2014 usually a fixture, still worth confirming it is not a live credential` : `Possible ${rule.name} detected`,
|
|
6626
|
+
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`,
|
|
6017
6627
|
consequence: rule.consequence,
|
|
6018
6628
|
cwe: rule.cwe,
|
|
6019
6629
|
excerpt: redactSecret(line.trim()).slice(0, 200),
|
|
@@ -6043,6 +6653,23 @@ function scanText(relativePath, text, language = languageOf(relativePath)) {
|
|
|
6043
6653
|
});
|
|
6044
6654
|
}
|
|
6045
6655
|
});
|
|
6656
|
+
for (const match of evaluateTemplateRules(extensionOf(relativePath), lines)) {
|
|
6657
|
+
const index = match.line - 1;
|
|
6658
|
+
if (isSuppressed(suppressions, index, match.rule.id)) continue;
|
|
6659
|
+
findings.push({
|
|
6660
|
+
ruleId: match.rule.id,
|
|
6661
|
+
title: match.rule.title,
|
|
6662
|
+
file: relativePath,
|
|
6663
|
+
line: match.line,
|
|
6664
|
+
severity: match.severity,
|
|
6665
|
+
confidence: "pattern",
|
|
6666
|
+
message: `${match.rule.title} (${match.rule.cwe})`,
|
|
6667
|
+
consequence: match.rule.consequence,
|
|
6668
|
+
cwe: match.rule.cwe,
|
|
6669
|
+
excerpt: (lines[index] ?? "").trim().slice(0, 200),
|
|
6670
|
+
category: "code"
|
|
6671
|
+
});
|
|
6672
|
+
}
|
|
6046
6673
|
return findings;
|
|
6047
6674
|
}
|
|
6048
6675
|
function scanManifest(relativePath, filename, text) {
|
|
@@ -6144,6 +6771,7 @@ function scanPath(targetPath, options = {}) {
|
|
|
6144
6771
|
const maxFileBytes = options.maxFileBytes ?? 1024 * 1024;
|
|
6145
6772
|
const allowed = options.categories ? new Set(options.categories) : null;
|
|
6146
6773
|
const findings = [];
|
|
6774
|
+
const controls = new ControlAudit();
|
|
6147
6775
|
const unreadable = [];
|
|
6148
6776
|
let filesScanned = 0;
|
|
6149
6777
|
let suppressed = 0;
|
|
@@ -6197,6 +6825,7 @@ function scanPath(targetPath, options = {}) {
|
|
|
6197
6825
|
filesScanned += 1;
|
|
6198
6826
|
options.onFile?.(relativePath);
|
|
6199
6827
|
suppressed += collectSuppressions(text.split("\n")).count;
|
|
6828
|
+
controls.observe(relativePath, text);
|
|
6200
6829
|
const fileFindings = [
|
|
6201
6830
|
...scanText(relativePath, text, declared ?? languageOf(filename)),
|
|
6202
6831
|
...isManifest ? scanManifest(relativePath, filename, text) : []
|
|
@@ -6239,6 +6868,7 @@ function scanPath(targetPath, options = {}) {
|
|
|
6239
6868
|
} else {
|
|
6240
6869
|
scanFile(targetPath, (0, import_node_path5.basename)(targetPath));
|
|
6241
6870
|
}
|
|
6871
|
+
if (options.missingControls) findings.push(...controls.findings());
|
|
6242
6872
|
const filtered = allowed ? findings.filter((f) => allowed.has(f.category)) : findings;
|
|
6243
6873
|
filtered.sort(
|
|
6244
6874
|
(a, b) => severityRank(b.severity) - severityRank(a.severity) || a.file.localeCompare(b.file) || a.line - b.line
|