@profullstack/threatcrush 0.11.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/index.js CHANGED
@@ -9580,6 +9580,347 @@ function workerId() {
9580
9580
  return `${import_node_os3.default.hostname()}/${process.pid}`;
9581
9581
  }
9582
9582
 
9583
+ // ../../packages/scan/src/node-rules.ts
9584
+ var PATH_CONTAINMENT_GUARD = /\bstartsWith\s*\(|\brelative\s*\(|\bnormalize\s*\(|\brealpath\b|\bisInside\b|\bwithin\s*\(|\bsanitiz\w*\b/i;
9585
+ 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['"]/;
9586
+ var HEADLESS_BROWSER = /\b(?:puppeteer|playwright|phantom|phantomjs|wkhtmltopdf|wkhtmltoimage|chrome-aws-lambda|html-pdf)\b/;
9587
+ var ORIGIN_ALLOWLIST_GUARD = /\b(?:includes|indexOf|has|test|find|some)\s*\(/;
9588
+ var FUNCTION_DESERIALIZER = /\b(?:node-serialize|serialize-to-js|funcster|cryo)\b/;
9589
+ var CONCATENATED_STRING = String.raw`(?:"[^"\n]*"|'[^'\n]*')\s*\+`;
9590
+ var INTERPOLATED_TEMPLATE = "`[^`\\n]*\\$\\{";
9591
+ var NODE_RULES = [
9592
+ // ── Code execution ───────────────────────────────────────────────────────
9593
+ {
9594
+ id: "js-vm-untrusted-execution",
9595
+ title: "script compiled and run by the `vm` module",
9596
+ 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.",
9597
+ cwe: "CWE-94",
9598
+ severity: "critical",
9599
+ languages: ["javascript", "typescript"],
9600
+ pattern: /\bvm\s*\.\s*(?:runInNewContext|runInThisContext|runInContext|compileFunction)\s*\(|\bnew\s+vm\s*\.\s*Script\s*\(/,
9601
+ // A `vm` call whose source is a build-time constant is a plugin loader, not
9602
+ // a vulnerability. The class only becomes real once the script text can be
9603
+ // influenced, so require that evidence before reporting at full severity.
9604
+ needsContext: true
9605
+ },
9606
+ {
9607
+ id: "js-vm2-sandbox",
9608
+ title: "`vm2` used as a sandbox",
9609
+ 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.",
9610
+ cwe: "CWE-1104",
9611
+ severity: "high",
9612
+ languages: ["javascript", "typescript"],
9613
+ pattern: /\bnew\s+(?:NodeVM|VMScript)\s*\(|\bfrom\s+['"]vm2['"]|require\s*\(\s*['"]vm2['"]\s*\)/,
9614
+ // Nothing on the surrounding lines changes the answer: the package itself
9615
+ // is the finding, the same way a broken cipher is.
9616
+ inherent: true
9617
+ },
9618
+ {
9619
+ id: "js-function-deserialization",
9620
+ title: "deserialiser that reconstructs functions",
9621
+ 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.",
9622
+ cwe: "CWE-502",
9623
+ severity: "critical",
9624
+ languages: ["javascript", "typescript"],
9625
+ pattern: /\b(?:unserialize|deepDeserialize|deserialize)\s*\(/,
9626
+ // Without this the rule fires on every project that happens to own a
9627
+ // function called `deserialize`, which is most of them. The import is what
9628
+ // makes the call the dangerous one.
9629
+ fileRequires: FUNCTION_DESERIALIZER,
9630
+ // The parse *is* the execution, so nearby input cannot make it worse and
9631
+ // its absence cannot make it safe. Nobody round-trips a constant through
9632
+ // these libraries.
9633
+ inherent: true
9634
+ },
9635
+ {
9636
+ id: "js-template-injection",
9637
+ title: "template compiled from a non-constant source",
9638
+ 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.",
9639
+ cwe: "CWE-1336",
9640
+ severity: "critical",
9641
+ languages: ["javascript", "typescript"],
9642
+ 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*\+)/,
9643
+ // Rendering a template held in a variable is the normal case — it was read
9644
+ // from a file at boot. Only the version where request data reaches the
9645
+ // template *body* is this class.
9646
+ needsContext: true
9647
+ },
9648
+ {
9649
+ id: "js-shelljs-command-execution",
9650
+ title: "shelljs command assembled from a string",
9651
+ consequence: "`shell.exec` runs its argument through a shell, so a `;` or backtick in an interpolated value runs as the server user.",
9652
+ cwe: "CWE-78",
9653
+ severity: "critical",
9654
+ languages: ["javascript", "typescript"],
9655
+ pattern: new RegExp(
9656
+ `\\b(?:shell|shelljs|sh)\\s*\\.\\s*exec\\s*\\(\\s*(?:${INTERPOLATED_TEMPLATE}|${CONCATENATED_STRING}|[a-zA-Z_$][\\w$]*\\s*\\+)`
9657
+ ),
9658
+ fileRequires: /\bshelljs\b/
9659
+ },
9660
+ // ── Injection ────────────────────────────────────────────────────────────
9661
+ {
9662
+ id: "js-nosql-where-expression",
9663
+ title: "MongoDB `$where` built by string assembly",
9664
+ 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.",
9665
+ cwe: "CWE-943",
9666
+ severity: "critical",
9667
+ languages: ["javascript", "typescript"],
9668
+ pattern: new RegExp(
9669
+ `\\$where\\s*['"]?\\s*:\\s*(?:${INTERPOLATED_TEMPLATE}|${CONCATENATED_STRING})|\\.\\s*\\$where\\s*=\\s*(?:${INTERPOLATED_TEMPLATE}|${CONCATENATED_STRING})`
9670
+ )
9671
+ },
9672
+ {
9673
+ id: "js-xpath-injection",
9674
+ title: "XPath expression built by concatenation",
9675
+ 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`.",
9676
+ cwe: "CWE-643",
9677
+ severity: "high",
9678
+ languages: ["javascript", "typescript"],
9679
+ // An XPath expression is recognisable by its own syntax — a descendant
9680
+ // axis or an attribute predicate. Matching on the *call* name alone would
9681
+ // flag every `select(` in the ecosystem. The quoted forms are split per
9682
+ // quote character for the reason given at CONCATENATED_STRING: the
9683
+ // predicate that makes it XPath usually contains the other quote.
9684
+ pattern: new RegExp(
9685
+ `\\b(?:xpath|xpathSelect|select|selectNodes|selectSingleNode|evaluate|find)\\s*\\(\\s*(?:\`[^\`\\n]*(?:\\/\\/|\\[@)[^\`\\n]*\\$\\{|"[^"\\n]*(?:\\/\\/|\\[@)[^"\\n]*"\\s*\\+|'[^'\\n]*(?:\\/\\/|\\[@)[^'\\n]*'\\s*\\+)`
9686
+ ),
9687
+ fileRequires: /\bxpath\b|\bxmldom\b|\blibxmljs\b|\bxpath\.js\b/
9688
+ },
9689
+ {
9690
+ id: "js-regex-from-input",
9691
+ title: "regular expression compiled from a variable",
9692
+ 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.",
9693
+ cwe: "CWE-1333",
9694
+ severity: "medium",
9695
+ languages: ["javascript", "typescript"],
9696
+ pattern: new RegExp(
9697
+ `\\bnew\\s+RegExp\\s*\\(\\s*(?:${INTERPOLATED_TEMPLATE}|${CONCATENATED_STRING}|(?!['"\`/])[a-zA-Z_$][\\w$.]*\\s*[,)])`
9698
+ ),
9699
+ needsContext: true
9700
+ },
9701
+ // ── XML ──────────────────────────────────────────────────────────────────
9702
+ {
9703
+ id: "js-xml-external-entities",
9704
+ title: "XML parser configured to resolve entities",
9705
+ 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.",
9706
+ cwe: "CWE-611",
9707
+ severity: "high",
9708
+ languages: ["javascript", "typescript"],
9709
+ // The option name is the finding. Every parser in the ecosystem defaults
9710
+ // these off, so an explicit `true` is a deliberate re-enable.
9711
+ pattern: /\b(?:noent|resolveEntities|expandEntities|externalEntities|resolveExternals)\s*:\s*(?:true|1)\b/,
9712
+ inherent: true
9713
+ },
9714
+ // ── Authentication and tokens ────────────────────────────────────────────
9715
+ {
9716
+ id: "js-jwt-none-algorithm",
9717
+ title: "JWT algorithm set to `none`",
9718
+ 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.",
9719
+ cwe: "CWE-347",
9720
+ severity: "critical",
9721
+ languages: ["javascript", "typescript"],
9722
+ pattern: /\balgorithms?\s*:\s*\[?\s*['"]none['"]/i,
9723
+ inherent: true
9724
+ },
9725
+ // ── Cryptography ─────────────────────────────────────────────────────────
9726
+ {
9727
+ id: "js-broken-cipher-algorithm",
9728
+ title: "broken cipher selected",
9729
+ 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.",
9730
+ cwe: "CWE-327",
9731
+ severity: "high",
9732
+ languages: ["javascript", "typescript"],
9733
+ pattern: /\bcreate(?:Cipher|Decipher)(?:iv)?\s*\(\s*['"](?:des|des3|des-ede\w*|3des|rc2|rc4|bf|blowfish|cast5?|idea|seed)\b/i,
9734
+ inherent: true
9735
+ },
9736
+ {
9737
+ id: "js-ecb-mode-cipher",
9738
+ title: "block cipher in ECB mode",
9739
+ 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.",
9740
+ cwe: "CWE-327",
9741
+ severity: "high",
9742
+ languages: ["javascript", "typescript"],
9743
+ pattern: /\bcreate(?:Cipher|Decipher)(?:iv)?\s*\(\s*['"][^'"\n]*-ecb\b/i,
9744
+ inherent: true
9745
+ },
9746
+ {
9747
+ id: "js-legacy-cipher-api",
9748
+ title: "deprecated `createCipher` used",
9749
+ 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.",
9750
+ cwe: "CWE-327",
9751
+ severity: "high",
9752
+ languages: ["javascript", "typescript"],
9753
+ // `createCipheriv` is the correct API and shares the prefix, so the
9754
+ // negative look-ahead is what separates the finding from the fix.
9755
+ pattern: /\bcrypto\s*\.\s*create(?:Cipher|Decipher)\s*\(/,
9756
+ lineGuard: /create(?:Cipher|Decipher)iv\s*\(/,
9757
+ inherent: true
9758
+ },
9759
+ // ── Cross-site scripting ─────────────────────────────────────────────────
9760
+ {
9761
+ id: "js-template-autoescape-disabled",
9762
+ title: "template auto-escaping turned off",
9763
+ 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.",
9764
+ cwe: "CWE-79",
9765
+ severity: "high",
9766
+ languages: ["javascript", "typescript"],
9767
+ pattern: /\bautoescape\s*:\s*false\b|\bescape\s*:\s*false\b|\bnoEscape\s*:\s*true\b/,
9768
+ inherent: true
9769
+ },
9770
+ {
9771
+ id: "js-serialize-javascript-unsafe",
9772
+ title: "`serialize-javascript` in unsafe mode",
9773
+ 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.",
9774
+ cwe: "CWE-79",
9775
+ severity: "high",
9776
+ languages: ["javascript", "typescript"],
9777
+ pattern: /\bunsafe\s*:\s*true\b/,
9778
+ fileRequires: /\bserialize-javascript\b/,
9779
+ inherent: true
9780
+ },
9781
+ {
9782
+ id: "js-cors-origin-reflected",
9783
+ title: "CORS origin reflected from the request",
9784
+ 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.",
9785
+ cwe: "CWE-942",
9786
+ severity: "high",
9787
+ languages: ["javascript", "typescript"],
9788
+ // Both spellings of the same mistake: writing the header directly, and
9789
+ // handing the request's origin to a CORS middleware's `origin` option.
9790
+ //
9791
+ // An earlier draft also matched a bare variable — `setHeader('…', origin)`
9792
+ // — which is wrong. A variable holding a *validated* origin is exactly
9793
+ // what the correct implementation looks like, and the rule cannot tell the
9794
+ // two apart. Only the visible read from the request qualifies.
9795
+ pattern: /\bAccess-Control-Allow-Origin['"]\s*,\s*(?:req|request|ctx)\s*\.|\borigin\s*:\s*(?:req|request|ctx)\s*\./,
9796
+ // Reflecting the header is the defect whatever surrounds it; there is no
9797
+ // arrangement of nearby lines that makes an echoed origin safe — except a
9798
+ // membership test on the value, which is what the guard looks for.
9799
+ inherent: true,
9800
+ guard: ORIGIN_ALLOWLIST_GUARD
9801
+ },
9802
+ // ── Server-side request forgery ──────────────────────────────────────────
9803
+ {
9804
+ id: "js-headless-browser-navigation",
9805
+ title: "headless browser sent to a non-constant URL",
9806
+ 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.",
9807
+ cwe: "CWE-918",
9808
+ severity: "high",
9809
+ languages: ["javascript", "typescript"],
9810
+ pattern: /\.\s*(?:goto|setContent|navigate)\s*\(\s*(?:`[^`\n]*\$\{|(?!['"`])[a-zA-Z_$][\w$.]*\s*[,)])/,
9811
+ fileRequires: HEADLESS_BROWSER,
9812
+ needsContext: true
9813
+ },
9814
+ // ── Path handling ────────────────────────────────────────────────────────
9815
+ {
9816
+ id: "js-archive-entry-path",
9817
+ title: "archive entry written to a path built from its own name",
9818
+ 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.",
9819
+ cwe: "CWE-22",
9820
+ severity: "high",
9821
+ languages: ["javascript", "typescript"],
9822
+ pattern: /\b(?:join|resolve)\s*\(\s*[^,\n)]+,\s*[a-zA-Z_$][\w$]*\s*\.\s*(?:entryName|fileName|filename|name|path)\b/,
9823
+ fileRequires: ARCHIVE_LIBRARY,
9824
+ guard: PATH_CONTAINMENT_GUARD,
9825
+ // The containment check comes *after* the join — you build the path, then
9826
+ // verify it stayed inside. A backwards-only window would miss every
9827
+ // correct implementation and report the safe code alongside the unsafe.
9828
+ guardForward: 3
9829
+ },
9830
+ // ── Electron ─────────────────────────────────────────────────────────────
9831
+ {
9832
+ id: "js-electron-node-integration",
9833
+ title: "Electron renderer given Node access",
9834
+ 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.",
9835
+ cwe: "CWE-1188",
9836
+ severity: "high",
9837
+ languages: ["javascript", "typescript"],
9838
+ pattern: /\bnodeIntegration(?:InWorker|InSubFrames)?\s*:\s*true\b|\bcontextIsolation\s*:\s*false\b|\benableRemoteModule\s*:\s*true\b|\bsandbox\s*:\s*false\b/,
9839
+ inherent: true
9840
+ },
9841
+ {
9842
+ id: "js-electron-web-security-disabled",
9843
+ title: "Electron web security disabled",
9844
+ 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.",
9845
+ cwe: "CWE-1173",
9846
+ severity: "high",
9847
+ languages: ["javascript", "typescript"],
9848
+ pattern: /\bwebSecurity\s*:\s*false\b|\ballowRunningInsecureContent\s*:\s*true\b|\bwebviewTag\s*:\s*true\b/,
9849
+ inherent: true
9850
+ },
9851
+ {
9852
+ id: "js-electron-open-external",
9853
+ title: "Electron `openExternal` with a non-constant URL",
9854
+ 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.",
9855
+ cwe: "CWE-749",
9856
+ severity: "high",
9857
+ languages: ["javascript", "typescript"],
9858
+ pattern: /\bshell\s*\.\s*openExternal\s*\(\s*(?:`[^`\n]*\$\{|(?!['"`])[a-zA-Z_$][\w$.]*\s*[,)])/,
9859
+ needsContext: true
9860
+ },
9861
+ // ── Hardening and resource limits ────────────────────────────────────────
9862
+ {
9863
+ id: "js-helmet-protection-disabled",
9864
+ title: "security header explicitly disabled",
9865
+ 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.",
9866
+ cwe: "CWE-693",
9867
+ severity: "medium",
9868
+ languages: ["javascript", "typescript"],
9869
+ pattern: /\b(?:contentSecurityPolicy|frameguard|hsts|noSniff|xssFilter|hidePoweredBy|referrerPolicy|dnsPrefetchControl|ieNoOpen|permittedCrossDomainPolicies|crossOriginEmbedderPolicy|crossOriginOpenerPolicy|crossOriginResourcePolicy|originAgentCluster)\s*:\s*false\b/,
9870
+ inherent: true
9871
+ },
9872
+ {
9873
+ id: "js-buffer-bounds-check-disabled",
9874
+ title: "buffer bounds checking turned off",
9875
+ 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.",
9876
+ cwe: "CWE-125",
9877
+ severity: "medium",
9878
+ languages: ["javascript", "typescript"],
9879
+ // The trailing `true` on a Buffer numeric accessor *is* `noAssert` — it is
9880
+ // the last positional parameter of every one of these methods. Anchoring
9881
+ // on the accessor name is what keeps this from matching `, true)` on any
9882
+ // call in the codebase.
9883
+ 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/,
9884
+ inherent: true
9885
+ },
9886
+ {
9887
+ id: "js-uninitialized-buffer",
9888
+ title: "buffer allocated without zeroing",
9889
+ 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.",
9890
+ cwe: "CWE-908",
9891
+ severity: "medium",
9892
+ languages: ["javascript", "typescript"],
9893
+ pattern: /\bBuffer\s*\.\s*allocUnsafe(?:Slow)?\s*\(|\bnew\s+Buffer\s*\(\s*(?![`'"])[a-zA-Z_$0-9]/,
9894
+ inherent: true
9895
+ },
9896
+ {
9897
+ id: "js-oversized-request-body-limit",
9898
+ title: "request body limit raised to a very large value",
9899
+ 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.",
9900
+ cwe: "CWE-400",
9901
+ severity: "medium",
9902
+ languages: ["javascript", "typescript"],
9903
+ // Two or more digits before `mb` — 50mb and up. A limit is a *good* thing;
9904
+ // only an ineffective one is the finding.
9905
+ pattern: /\blimit\s*:\s*['"]\s*(?:[5-9]\d|\d{3,})\s*mb\s*['"]|\blimit\s*:\s*['"]\s*\d+\s*gb\s*['"]/i,
9906
+ inherent: true
9907
+ },
9908
+ // ── Information disclosure ───────────────────────────────────────────────
9909
+ {
9910
+ id: "js-error-detail-returned",
9911
+ title: "error object or stack trace sent to the client",
9912
+ 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.",
9913
+ cwe: "CWE-209",
9914
+ severity: "medium",
9915
+ languages: ["javascript", "typescript"],
9916
+ // `res.status(500).send(…)` is the overwhelmingly common spelling, so the
9917
+ // optional `.status(…)` hop is not a nicety — without it the rule misses
9918
+ // nearly every real occurrence.
9919
+ 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*[,}]/,
9920
+ inherent: true
9921
+ }
9922
+ ];
9923
+
9583
9924
  // ../../packages/scan/src/types.ts
9584
9925
  var SEVERITY_ORDER = ["info", "low", "medium", "high", "critical"];
9585
9926
  function severityRank(severity) {
@@ -9626,7 +9967,20 @@ var GENERIC_GUARD = (
9626
9967
  // The identifier must END at the escaper (with at most a known output-context
9627
9968
  // suffix). An earlier, looser form also matched `describe(`, which would have
9628
9969
  // silenced findings across every test file in every repository.
9629
- /\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
9970
+ // The `Access-Control-` lookbehind is not a nicety. This regex is
9971
+ // case-insensitive, and `Access-Control-Allow-Origin` contains the word
9972
+ // "Allow" followed by a non-word character — so the CORS header name matched
9973
+ // the allow-list heuristic. Because the guard is tested against an 8-line
9974
+ // *window*, one header line silently disabled every guardable rule near it:
9975
+ // in a four-line Express error handler, the header on one line suppressed
9976
+ // both the `none`-algorithm JWT finding and the returned stack trace below
9977
+ // it. The failure mode is the dangerous kind — not fewer findings, none, and
9978
+ // indistinguishable from clean code.
9979
+ //
9980
+ // Scoped to the header prefix rather than to a bare `allow(?!-)`, because
9981
+ // this guard also runs over `.yml` and `.conf` files, where `allow-list:`
9982
+ // and `allowed-hosts:` are ordinary keys that should still guard.
9983
+ /(?<!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
9630
9984
  );
9631
9985
  var XXE_GUARD = /FEATURE_SECURE_PROCESSING|setExpandEntityReferences|disallow-doctype-decl|external-general-entities|external-parameter-entities|setXIncludeAware\s*\(\s*false/;
9632
9986
  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/;
@@ -10054,6 +10408,20 @@ var CODE_RULES = [
10054
10408
  consequence: "Catastrophic backtracking: a crafted input of a few dozen characters pins a CPU core for minutes.",
10055
10409
  cwe: "CWE-1333",
10056
10410
  severity: "medium",
10411
+ // Scoped, because unscoped this rule reads C as if it were a regex.
10412
+ //
10413
+ // `(void *)*memptr64` — a cast to a pointer type, then a dereference — is
10414
+ // the single most ordinary line in a C file, and it matches the first
10415
+ // alternative exactly: `(`, some text, `*`, `)`, `*`. Every `*(char *)*p`
10416
+ // in a codebase became a ReDoS finding. Caught on inspektor-gadget, where
10417
+ // the rule's one and only hit across 1186 files was a `bpf_probe_read_user`
10418
+ // call in an eBPF C program.
10419
+ //
10420
+ // Scoping is the fix rather than a cleverer pattern: C has no regex
10421
+ // literals, so there is nothing here for the rule to find no matter how
10422
+ // the pattern is written. `other` also covers C++, Rust and Zig, which
10423
+ // share the cast-then-deref spelling.
10424
+ languages: ["javascript", "typescript", "python", "ruby", "go", "java", "php"],
10057
10425
  pattern: /\([^)\n]*[+*]\s*\)\s*[+*]|\([^)\n]*\{\d+,\}\s*\)\s*[+*{]/
10058
10426
  },
10059
10427
  // ── Temporary files ──────────────────────────────────────────────────────
@@ -10578,7 +10946,7 @@ var CODE_RULES = [
10578
10946
  severity: "high",
10579
10947
  languages: ["javascript", "typescript"],
10580
10948
  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/
10581
- }
10949
+ },
10582
10950
  // A recursive-merge prototype-pollution rule (`target[key] = source[key]`
10583
10951
  // with no `__proto__` guard) was built and dropped. The bare copy-by-key is
10584
10952
  // the safe allow-listed shape (`updates[field] = body[field]` over an
@@ -10588,6 +10956,11 @@ var CODE_RULES = [
10588
10956
  // answer. It flagged legitimate merges in Capacitor and in this repo's own
10589
10957
  // web app. `js-prototype-pollution` still catches the explicit `__proto__`
10590
10958
  // literal; the recursive-merge case is left to KNOWN_GAPS.
10959
+ // Node-ecosystem classes — vm2, Electron, JWT, archive extraction, headless
10960
+ // browsers — are kept in their own table because each has to know which
10961
+ // package it is looking at before it can claim anything. Folded in here so
10962
+ // there stays exactly one rule list for every consumer to iterate.
10963
+ ...NODE_RULES
10591
10964
  ];
10592
10965
  var COMMENT_PREFIX = /^\s*(?:\/\/|\/\*|\*|#|--|<!--)/;
10593
10966
  var DEFINITION_PREFIX = /^\s*(?:(?:export|public|private|protected|static|final|async|abstract)\s+)*(?:def|function|func|class|module|interface|struct|impl|fn|sub)\b/;
@@ -10662,6 +11035,188 @@ function evaluateRule(rule, ctx) {
10662
11035
  return { rule, confidence, severity: severityFor(rule.severity, confidence) };
10663
11036
  }
10664
11037
 
11038
+ // ../../packages/scan/src/template-rules.ts
11039
+ var LAYOUT_SLOT = /\{\{\{\s*(?:body|content|outlet|children)\s*\}\}\}/;
11040
+ 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.";
11041
+ var TEMPLATE_RULES = [
11042
+ {
11043
+ id: "tpl-handlebars-unescaped",
11044
+ title: "Handlebars/Mustache interpolation that skips escaping",
11045
+ consequence: XSS_CONSEQUENCE,
11046
+ cwe: "CWE-79",
11047
+ severity: "high",
11048
+ extensions: [".hbs", ".handlebars", ".hdbs", ".mustache", ".ms"],
11049
+ // Two spellings of the same opt-out: the triple-stache and the `&` prefix.
11050
+ pattern: /\{\{\{(?!\{)[^}]*\}\}\}|\{\{\s*&\s*[^}]+\}\}/,
11051
+ lineGuard: LAYOUT_SLOT
11052
+ },
11053
+ {
11054
+ id: "tpl-vue-v-html",
11055
+ title: "`v-html` binding",
11056
+ consequence: XSS_CONSEQUENCE,
11057
+ cwe: "CWE-79",
11058
+ severity: "high",
11059
+ extensions: [".vue", ".html", ".htm"],
11060
+ pattern: /\bv-html\s*=|\s:inner-html\.prop\s*=/
11061
+ },
11062
+ {
11063
+ id: "tpl-pug-unescaped",
11064
+ title: "Pug/Jade unescaped interpolation",
11065
+ consequence: XSS_CONSEQUENCE,
11066
+ cwe: "CWE-79",
11067
+ severity: "high",
11068
+ extensions: [".pug", ".jade"],
11069
+ // `!{…}` is unescaped interpolation anywhere on the line. `!=` is
11070
+ // unescaped buffered code, but only in the tag position — anchored to the
11071
+ // start of the line and preceded by nothing but tag, class and id
11072
+ // characters, so the `!==` inside `- if (a !== b)` cannot reach it.
11073
+ pattern: /!\{[^}\n]*\}|^\s*[\w.#%-]*!=(?!=)\s*\S/
11074
+ },
11075
+ {
11076
+ id: "tpl-ejs-raw-output",
11077
+ title: "EJS/ECT raw output tag",
11078
+ consequence: XSS_CONSEQUENCE,
11079
+ cwe: "CWE-79",
11080
+ severity: "high",
11081
+ extensions: [".ejs", ".ect"],
11082
+ // In EJS `<%= %>` escapes and `<%-` does not, which is the reverse of
11083
+ // Underscore's convention — see KNOWN_GAPS.
11084
+ pattern: /<%-(?!\s*(?:include|-))/,
11085
+ // `<%- include('partial') %>` splices another template, not user data.
11086
+ lineGuard: /<%-\s*(?:include|partial)\s*\(/
11087
+ },
11088
+ {
11089
+ id: "tpl-dust-escape-filter-off",
11090
+ title: "Dust reference with escaping suppressed",
11091
+ consequence: XSS_CONSEQUENCE,
11092
+ cwe: "CWE-79",
11093
+ severity: "high",
11094
+ extensions: [".dust", ".tl"],
11095
+ // The `|s` filter means "suppress the default HTML escape".
11096
+ pattern: /\{[^{}\n]+\|\s*s\s*\}/
11097
+ },
11098
+ {
11099
+ id: "tpl-jinja-safe-filter",
11100
+ title: "Nunjucks/Twig/Jinja `safe` filter or autoescape block off",
11101
+ consequence: XSS_CONSEQUENCE,
11102
+ cwe: "CWE-79",
11103
+ severity: "high",
11104
+ extensions: [".njk", ".nunjucks", ".twig", ".jinja", ".jinja2", ".j2"],
11105
+ pattern: /\|\s*(?:safe|raw)\s*(?:\}\}|\|)|\{%\s*autoescape\s+(?:false|off)\s*%\}/
11106
+ },
11107
+ {
11108
+ id: "tpl-haml-unescaped",
11109
+ title: "Haml unescaped output",
11110
+ consequence: XSS_CONSEQUENCE,
11111
+ cwe: "CWE-79",
11112
+ severity: "high",
11113
+ extensions: [".haml"],
11114
+ pattern: /^\s*[\w.#%-]*!=(?!=)\s*\S/
11115
+ }
11116
+ ];
11117
+ var TEMPLATE_EXTENSIONS = new Set(
11118
+ TEMPLATE_RULES.flatMap((rule) => rule.extensions)
11119
+ );
11120
+ function evaluateTemplateRules(extension, lines) {
11121
+ const ext = extension.toLowerCase();
11122
+ const applicable = TEMPLATE_RULES.filter((rule) => rule.extensions.includes(ext));
11123
+ if (applicable.length === 0) return [];
11124
+ const matches = [];
11125
+ lines.forEach((line, index) => {
11126
+ for (const rule of applicable) {
11127
+ if (!rule.pattern.test(line)) continue;
11128
+ if (rule.lineGuard?.test(line)) continue;
11129
+ matches.push({
11130
+ rule,
11131
+ // A template cannot show that the value is untrusted, so the claim
11132
+ // never rises above `pattern` and the shared cap holds it at medium.
11133
+ severity: severityFor(rule.severity, "pattern"),
11134
+ line: index + 1
11135
+ });
11136
+ }
11137
+ });
11138
+ return matches;
11139
+ }
11140
+
11141
+ // ../../packages/scan/src/controls.ts
11142
+ 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*\(/;
11143
+ var SECURITY_CONTROLS = [
11144
+ {
11145
+ id: "control-security-headers-absent",
11146
+ title: "no security header middleware",
11147
+ 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.",
11148
+ cwe: "CWE-693",
11149
+ severity: "medium",
11150
+ 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
11151
+ },
11152
+ {
11153
+ id: "control-anti-csrf-absent",
11154
+ title: "no cross-site request forgery protection",
11155
+ 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.",
11156
+ cwe: "CWE-352",
11157
+ severity: "medium",
11158
+ // SameSite counts. A cookie the browser refuses to send cross-site is not
11159
+ // reachable by the attack this control exists to stop, so a project that
11160
+ // chose that route instead of tokens has the control, not a gap.
11161
+ evidence: /\bcsurf\b|\bcsrf\b|\bxsrf\b|\blusca\b|\b@fastify\/csrf\b|\bdouble-csrf\b|\bsameSite\s*:\s*['"](?:strict|lax)['"]/i
11162
+ },
11163
+ {
11164
+ id: "control-rate-limiting-absent",
11165
+ title: "no request rate limiting",
11166
+ 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.",
11167
+ cwe: "CWE-770",
11168
+ severity: "medium",
11169
+ evidence: /\bexpress-rate-limit\b|\brateLimit\b|\brate-limiter\b|\bratelimit\b|\bexpress-slow-down\b|\bslowDown\b|\bbottleneck\b|\bthrottle\b/i
11170
+ },
11171
+ {
11172
+ id: "control-body-size-limit-absent",
11173
+ title: "no request body size limit",
11174
+ 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.",
11175
+ cwe: "CWE-400",
11176
+ severity: "medium",
11177
+ evidence: /\blimit\s*:\s*['"]?\d+\s*(?:kb|mb|b)\b|\bbodyLimit\b|\bmaxRequestBodySize\b|\bclient_max_body_size\b/i
11178
+ }
11179
+ ];
11180
+ var ControlAudit = class {
11181
+ seen = /* @__PURE__ */ new Set();
11182
+ /** Where the server is built. Anchors the findings somewhere meaningful. */
11183
+ serverFile = null;
11184
+ observe(relativePath, text) {
11185
+ if (this.serverFile === null && SERVER_FRAMEWORK.test(text)) {
11186
+ this.serverFile = relativePath;
11187
+ }
11188
+ for (const control of SECURITY_CONTROLS) {
11189
+ if (this.seen.has(control.id)) continue;
11190
+ if (control.evidence.test(text)) this.seen.add(control.id);
11191
+ }
11192
+ }
11193
+ /** Controls with no evidence anywhere. Empty when the tree serves no HTTP. */
11194
+ missing() {
11195
+ if (this.serverFile === null) return [];
11196
+ return SECURITY_CONTROLS.filter((control) => !this.seen.has(control.id));
11197
+ }
11198
+ findings() {
11199
+ const anchor = this.serverFile;
11200
+ if (anchor === null) return [];
11201
+ return this.missing().map((control) => ({
11202
+ ruleId: control.id,
11203
+ title: control.title,
11204
+ file: anchor,
11205
+ line: 1,
11206
+ // `pattern` is doing real work here: it caps the severity, and it says
11207
+ // in the report itself how much the scanner is claiming. An absence is
11208
+ // never `evidence`.
11209
+ severity: severityFor(control.severity, "pattern"),
11210
+ confidence: "pattern",
11211
+ message: `${control.title} \u2014 no evidence of one anywhere in the scanned tree (${control.cwe})`,
11212
+ consequence: control.consequence,
11213
+ cwe: control.cwe,
11214
+ excerpt: "",
11215
+ category: "code"
11216
+ }));
11217
+ }
11218
+ };
11219
+
10665
11220
  // ../../packages/scan/src/manifest-rules.ts
10666
11221
  var POPULAR_NPM = [
10667
11222
  "react",
@@ -11093,7 +11648,18 @@ var KNOWN_PLACEHOLDERS = [
11093
11648
  /\bEXAMPLE_?KEY\b/i,
11094
11649
  /\bYOUR_[A-Z_]*(?:KEY|TOKEN|SECRET|PASSWORD)\b/,
11095
11650
  /\b(?:xxx+|X{4,}|\*{4,}|<[a-z-]+>)\b/,
11096
- /\bchangeme\b/i
11651
+ /\bchangeme\b/i,
11652
+ // The metasyntactic pair in a connection string, `postgres://user:pass@host`.
11653
+ // This is not the AWS case above and the distinction is the whole reason it
11654
+ // is allowed: `AKIAIOSFODNN7EXAMPLE` is a real credential *format* carrying a
11655
+ // fake value, so it arrives by way of a pasted template. `user:pass` is the
11656
+ // English words sitting where a credential goes, which is how every database
11657
+ // driver writes its DSN in its own README, and nobody pastes that out of a
11658
+ // secret manager.
11659
+ //
11660
+ // Both halves have to be metasyntactic. `root:hunter2@` is not exempt, since
11661
+ // a real password beside a common username is the case this must not swallow.
11662
+ /:\/\/(?:user(?:name)?|admin|root|dbuser|myuser):(?:pass(?:word|wd)?|secret|dbpass|mypassword)@/i
11097
11663
  ];
11098
11664
  function isKnownPlaceholder(text) {
11099
11665
  return KNOWN_PLACEHOLDERS.some((pattern) => pattern.test(text));
@@ -11198,7 +11764,12 @@ var SCAN_EXTENSIONS = /* @__PURE__ */ new Set([
11198
11764
  ".erb",
11199
11765
  ".ejs",
11200
11766
  ".vue",
11201
- ".svelte"
11767
+ ".svelte",
11768
+ // Template files, so the engine in `template-rules.ts` has something to
11769
+ // read. Sourced from the rules themselves rather than repeated here: an
11770
+ // engine added there becomes scannable without a second edit that could be
11771
+ // forgotten, which is how a rule ends up quietly never firing.
11772
+ ...TEMPLATE_EXTENSIONS
11202
11773
  ]);
11203
11774
  var LANGUAGE_BY_EXTENSION = {
11204
11775
  ".js": "javascript",
@@ -11270,6 +11841,10 @@ function languageOfShebang(firstLine) {
11270
11841
  }
11271
11842
  var SUPPRESS_NEXT = /threatcrush-disable-next-line(?:\s+([\w-]+))?/;
11272
11843
  var SUPPRESS_LINE = /threatcrush-disable-line(?:\s+([\w-]+))?/;
11844
+ var FOREIGN_CREDENTIAL = /\b(?:nolint:[\w,]*gosec|nosec)\b[^\n]*\bG101\b|\bG101\b[^\n]*\b(?:nolint:[\w,]*gosec|nosec)\b/;
11845
+ function foreignCredentialMark(line) {
11846
+ return FOREIGN_CREDENTIAL.test(line);
11847
+ }
11273
11848
  function collectSuppressions(lines) {
11274
11849
  const byLine = /* @__PURE__ */ new Map();
11275
11850
  let count = 0;
@@ -11307,16 +11882,18 @@ function scanText(relativePath, text, language = languageOf(relativePath)) {
11307
11882
  if (!match) continue;
11308
11883
  if (isKnownPlaceholder(match[0])) continue;
11309
11884
  if (isSuppressed(suppressions, index, rule.id)) continue;
11885
+ const marked = foreignCredentialMark(line);
11310
11886
  findings.push({
11311
11887
  ruleId: rule.id,
11312
11888
  title: rule.name,
11313
11889
  file: relativePath,
11314
11890
  line: index + 1,
11315
- // Reported but not blocking in tests — see isTestPath.
11316
- severity: inTests ? "low" : rule.severity,
11891
+ // Reported but not blocking in tests — see isTestPath. The same goes
11892
+ // for a line another linter's credential rule was already told about.
11893
+ severity: inTests || marked ? "low" : rule.severity,
11317
11894
  // A matched credential format is the finding, not a proxy for one.
11318
11895
  confidence: "evidence",
11319
- 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`,
11896
+ 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`,
11320
11897
  consequence: rule.consequence,
11321
11898
  cwe: rule.cwe,
11322
11899
  excerpt: redactSecret(line.trim()).slice(0, 200),
@@ -11346,6 +11923,23 @@ function scanText(relativePath, text, language = languageOf(relativePath)) {
11346
11923
  });
11347
11924
  }
11348
11925
  });
11926
+ for (const match of evaluateTemplateRules(extensionOf(relativePath), lines)) {
11927
+ const index = match.line - 1;
11928
+ if (isSuppressed(suppressions, index, match.rule.id)) continue;
11929
+ findings.push({
11930
+ ruleId: match.rule.id,
11931
+ title: match.rule.title,
11932
+ file: relativePath,
11933
+ line: match.line,
11934
+ severity: match.severity,
11935
+ confidence: "pattern",
11936
+ message: `${match.rule.title} (${match.rule.cwe})`,
11937
+ consequence: match.rule.consequence,
11938
+ cwe: match.rule.cwe,
11939
+ excerpt: (lines[index] ?? "").trim().slice(0, 200),
11940
+ category: "code"
11941
+ });
11942
+ }
11349
11943
  return findings;
11350
11944
  }
11351
11945
  function scanManifest(relativePath, filename, text) {
@@ -11452,6 +12046,7 @@ function scanPath(targetPath, options = {}) {
11452
12046
  const maxFileBytes = options.maxFileBytes ?? 1024 * 1024;
11453
12047
  const allowed = options.categories ? new Set(options.categories) : null;
11454
12048
  const findings = [];
12049
+ const controls = new ControlAudit();
11455
12050
  const unreadable = [];
11456
12051
  let filesScanned = 0;
11457
12052
  let suppressed = 0;
@@ -11505,6 +12100,7 @@ function scanPath(targetPath, options = {}) {
11505
12100
  filesScanned += 1;
11506
12101
  options.onFile?.(relativePath);
11507
12102
  suppressed += collectSuppressions(text.split("\n")).count;
12103
+ controls.observe(relativePath, text);
11508
12104
  const fileFindings = [
11509
12105
  ...scanText(relativePath, text, declared ?? languageOf(filename)),
11510
12106
  ...isManifest ? scanManifest(relativePath, filename, text) : []
@@ -11547,6 +12143,7 @@ function scanPath(targetPath, options = {}) {
11547
12143
  } else {
11548
12144
  scanFile(targetPath, (0, import_node_path2.basename)(targetPath));
11549
12145
  }
12146
+ if (options.missingControls) findings.push(...controls.findings());
11550
12147
  const filtered = allowed ? findings.filter((f) => allowed.has(f.category)) : findings;
11551
12148
  filtered.sort(
11552
12149
  (a, b) => severityRank(b.severity) - severityRank(a.severity) || a.file.localeCompare(b.file) || a.line - b.line
@@ -11901,6 +12498,7 @@ async function scanCommand(targetPath, options = {}) {
11901
12498
  let seen = 0;
11902
12499
  const report = scanPath(targetPath, {
11903
12500
  exclude: options.exclude,
12501
+ missingControls: options.missingControls,
11904
12502
  onFile: () => {
11905
12503
  seen += 1;
11906
12504
  if (spinner) spinner.text = `Scanning files... (${seen} files)`;
@@ -17579,6 +18177,9 @@ program2.command("scan").description("Scan codebase for vulnerabilities and secr
17579
18177
  "skip paths matching this glob (repeatable); merged with a .threatcrushignore at the scan root",
17580
18178
  (value, previous) => [...previous, value],
17581
18179
  []
18180
+ ).option(
18181
+ "--missing-controls",
18182
+ "also report security controls the tree shows no evidence of (headers, CSRF, rate limiting, body size limits)"
17582
18183
  ).option("-v, --verbose", "list the paths that could not be read").action(async (targetPath, opts) => {
17583
18184
  const format = (opts.format ?? "text").toLowerCase();
17584
18185
  if (!["text", "json", "sarif"].includes(format)) {
@@ -17599,6 +18200,7 @@ program2.command("scan").description("Scan codebase for vulnerabilities and secr
17599
18200
  pathPrefix: opts.pathPrefix,
17600
18201
  dependencies: opts.deps,
17601
18202
  exclude: opts.exclude,
18203
+ missingControls: opts.missingControls,
17602
18204
  verbose: opts.verbose
17603
18205
  });
17604
18206
  });