@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 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
- /\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
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. The safe form escapes, so the window carries an escaper.
5207
- pattern: /\bf['"][^'"\n]*\([&|][^'"\n]*\{[^}\n]+\}/,
5208
- guard: /escap/i
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
- pattern: /\bf['"][^'"\n]*(?:\/\/|\/\w+\[)[^'"\n]*\{[^}\n]+\}/
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
- severity: inTests ? "low" : rule.severity,
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