@profullstack/threatcrush 0.5.1 → 0.6.0

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
@@ -4431,8 +4431,14 @@ var UNTRUSTED_PY = /\brequest\b|\bparams\b|\bflask\b|\bsys\.argv\b|\bos\.environ
4431
4431
  var UNTRUSTED_RB = /\bparams\s*\[|\brequest\b|\bcookies\s*\[/;
4432
4432
  var UNTRUSTED_GO = /\br\s*\.\s*(?:URL|Form|Body|Header|PostForm)\b|\bFormValue\s*\(|\bQuery\s*\(\s*\)\s*\.\s*Get\s*\(|\bmux\.Vars\s*\(/;
4433
4433
  var UNTRUSTED_JAVA = /\bgetParameter\s*\(|\bgetQueryString\s*\(|\bgetHeader\s*\(|\bgetInputStream\s*\(|\bgetCookies\s*\(|\b@RequestParam\b|\b@PathVariable\b/;
4434
+ var UNTRUSTED_SH = /\$\{?[1-9]\d*\b|\$[@*]|\$\{@\}|\bread\s+(?:-\S+\s+)*[A-Za-z_]\w*|\$\{?REPLY\b|\$\{?QUERY_STRING\b/;
4435
+ var UNTRUSTED_PHP = /\$_(?:GET|POST|REQUEST|COOKIE|FILES|SERVER)\b|\bphp:\/\/input\b|\bgetallheaders\s*\(/;
4434
4436
  function untrustedPatternFor(language) {
4435
4437
  switch (language) {
4438
+ case "php":
4439
+ return UNTRUSTED_PHP;
4440
+ case "shell":
4441
+ return UNTRUSTED_SH;
4436
4442
  case "python":
4437
4443
  return UNTRUSTED_PY;
4438
4444
  case "ruby":
@@ -4457,6 +4463,7 @@ var GENERIC_GUARD = (
4457
4463
  /\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
4458
4464
  );
4459
4465
  var XXE_GUARD = /FEATURE_SECURE_PROCESSING|setExpandEntityReferences|disallow-doctype-decl|external-general-entities|external-parameter-entities|setXIncludeAware\s*\(\s*false/;
4466
+ 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/;
4460
4467
  var CODE_SINK = /\bglobalThis\s*\[|\bconstructor\b|\beval\b|\bFunction\b|\brun\s*\(|\bvm\s*\.\s*run/;
4461
4468
  var EXFIL_SINK = /\bconsole\s*\.\s*(?:log|debug|info|warn|error)\s*\(|\bfetch\s*\(|\baxios\b|\brequest\s*\(|\.\s*send\s*\(/;
4462
4469
  var SQL_KEYWORDS = "SELECT|INSERT\\s+INTO|INSERT|UPDATE|DELETE\\s+FROM|DELETE|DROP|UNION\\s+SELECT";
@@ -4731,6 +4738,16 @@ var CODE_RULES = [
4731
4738
  pattern: /\byaml\s*\.\s*load\s*\((?![^)]*safe)|\bloadAll\s*\([^)]*unsafe/i
4732
4739
  },
4733
4740
  // ── XML external entities ────────────────────────────────────────────────
4741
+ //
4742
+ // Evidence that a file parses XML at all. The XXE rules match on receiver
4743
+ // *names* — `builder.parse(is)` is the shape the vulnerability actually takes,
4744
+ // and the declared type is rarely on that line — so without this the pattern
4745
+ // reads any `.parse()` on anything suffixed Builder, Parser or Reader as XML.
4746
+ // In practice that meant a hostname-mask parser (`HostMask.Parser.parse(...)`)
4747
+ // was reported as CWE-611 at high severity.
4748
+ //
4749
+ // An import is the cheapest honest signal: a file that parses XML says so at
4750
+ // the top, and one that never mentions XML is not parsing it.
4734
4751
  {
4735
4752
  id: "java-xxe-parser-defaults",
4736
4753
  title: "XML parser left on its insecure defaults",
@@ -4751,7 +4768,10 @@ var CODE_RULES = [
4751
4768
  severity: "high",
4752
4769
  languages: ["java"],
4753
4770
  // Receiver-qualified so `LocalDate.parse(s)` and friends stay out of it.
4771
+ // The suffix alone is not enough — plenty of parsers parse things that are
4772
+ // not XML — so `fileRequires` decides whether the file is in scope at all.
4754
4773
  pattern: /\b\w*(?:[Bb]uilder|[Pp]arser|[Rr]eader)\s*\.\s*parse\s*\(/,
4774
+ fileRequires: XML_PARSING_FILE,
4755
4775
  guard: XXE_GUARD,
4756
4776
  guardBack: 6,
4757
4777
  guardForward: 4
@@ -4873,6 +4893,212 @@ var CODE_RULES = [
4873
4893
  severity: "high",
4874
4894
  languages: ["javascript", "typescript"],
4875
4895
  pattern: /\[\s*['"]__proto__['"]\s*\]|\bObject\s*\.\s*assign\s*\(\s*[\w.$]*\.prototype\b|\.\s*__proto__\s*=/
4896
+ },
4897
+ // ── Shell ────────────────────────────────────────────────────────────────
4898
+ //
4899
+ // `shell` was a language the type system knew about and no rule targeted, so
4900
+ // a repository written entirely in bash got secret detection and nothing
4901
+ // else. Installers, CI helpers and packaging scripts are where a great deal
4902
+ // of privileged work actually happens, and they run as whoever invoked them.
4903
+ {
4904
+ id: "sh-remote-script-execution",
4905
+ title: "network output piped into a shell",
4906
+ consequence: "Whatever that URL serves at the moment this runs is executed as the invoking user. There is no version, no signature, and no review \u2014 a compromise of the host, or anyone able to answer for it, is a compromise of every machine that runs the script.",
4907
+ cwe: "CWE-494",
4908
+ severity: "high",
4909
+ languages: ["shell"],
4910
+ // The pipe must be the *next* thing: `curl -o f url && sh f` is a different
4911
+ // (and checkable) shape, and `curl url | jq` is not an execution at all.
4912
+ pattern: /\b(?:curl|wget)\b[^|\n]*\|\s*(?:sudo\s+(?:-\S+\s+)*)?(?:\/bin\/|\/usr\/bin\/)?(?:ba|da|k|z|a)?sh\b/,
4913
+ // Nothing on this line can exonerate it. Integrity checking happens in a
4914
+ // separate step by construction, so a window guard would only mislead.
4915
+ guard: false
4916
+ },
4917
+ {
4918
+ id: "sh-eval-expansion",
4919
+ title: "eval on an expanded string",
4920
+ consequence: "The expansion is re-parsed as shell source, so a `;` or `$(\u2026)` anywhere in the value runs as a command rather than arriving as data.",
4921
+ cwe: "CWE-78",
4922
+ severity: "high",
4923
+ languages: ["shell"],
4924
+ // Matched against eval's *argument*, not against the rest of the line.
4925
+ //
4926
+ // `\beval\b.*\$` looks equivalent and is not: bash's ordinary dynamic-range
4927
+ // idiom, `eval echo {$((k + 1))..$((k + n))}`, contains `$k` inside the
4928
+ // arithmetic, so a line-wide search finds an expansion in a construct that
4929
+ // cannot carry a command — `$((…))` is parsed as an expression, where a `;`
4930
+ // is a syntax error rather than a second command. That spelling reported
4931
+ // 355 findings in one 3,500-line script, all of them the same safe loop.
4932
+ //
4933
+ // What is left is the form where eval is handed a value directly —
4934
+ // `eval "$cmd"`, `eval $cmd`, `eval "$(…)"` — which is the shape that
4935
+ // actually re-parses untrusted text as source. `eval echo $x` is not
4936
+ // covered; catching it without also catching the range idiom needs to know
4937
+ // which expansions are arithmetic, which is parsing, not matching.
4938
+ pattern: /\beval\s+(?:-\S+\s+)*(?:"\s*)?\$(?:\{?[A-Za-z_]\w*|\((?!\())/,
4939
+ // The shell-init idiom `eval "$(tool init -)"` is the documented interface
4940
+ // of most version managers. It is still eval of program output, but the
4941
+ // program is a fixed local binary, and flagging it reports every developer
4942
+ // dotfile in existence.
4943
+ lineGuard: /\beval\s+"?\$\(\s*(?:ssh-agent|dircolors|direnv|rbenv|pyenv|nodenv|goenv|jenv|tfenv|opam|luarocks|conda|mamba|zoxide|starship|mise|asdf|fnm|nvm|brew|thefuck|register-python-argcomplete|_\w+_completion)\b/
4944
+ },
4945
+ {
4946
+ id: "sh-unquoted-expansion-destructive",
4947
+ title: "unquoted expansion in a destructive command",
4948
+ consequence: "An unquoted expansion is word-split and glob-expanded before the command sees it. A value with a space removes two paths instead of one; an empty value removes the argument entirely, which is how `rm -rf $DIR/` becomes `rm -rf /`.",
4949
+ cwe: "CWE-78",
4950
+ severity: "high",
4951
+ languages: ["shell"],
4952
+ // `[^"'\n]*?` cannot cross a quote, so `rm -rf "$dir"` — the correct form —
4953
+ // never reaches the `$` and never matches. Only a genuinely bare expansion
4954
+ // does. Restricted to recursive/forced removal: a bare `$f` in `rm $f` is
4955
+ // sloppy, but it is not the shape that erases a filesystem.
4956
+ pattern: /\brm\s+(?:-[a-zA-Z-]*[rRf][a-zA-Z-]*\s+)+[^"'\n]*?\$\{?[A-Za-z_]/
4957
+ },
4958
+ {
4959
+ id: "sh-insecure-transport-flag",
4960
+ title: "certificate verification disabled",
4961
+ consequence: "Anyone positioned between this host and the server can substitute the response. When the response is a package, a key or a script, that is remote code execution with the transport doing nothing to stop it.",
4962
+ cwe: "CWE-295",
4963
+ severity: "high",
4964
+ languages: ["shell"],
4965
+ pattern: /\b(?:curl|wget)\b[^\n|]*(?:\s-k(?=\s|$)|\s--insecure\b|\s--no-check-certificate\b)/,
4966
+ // Over plain HTTP there is no certificate to skip, so the flag is inert and
4967
+ // this rule has nothing to say — `sh-plaintext-download` is the finding
4968
+ // that fits. Reporting both put two entries on one line, one of which
4969
+ // recommended a fix that would change nothing.
4970
+ lineGuard: /^(?!.*https:\/\/).*\bhttp:\/\//
4971
+ },
4972
+ {
4973
+ id: "sh-plaintext-download",
4974
+ title: "download over plain HTTP",
4975
+ consequence: "The response arrives unauthenticated over a channel any intermediary can rewrite. Where the payload is an archive, a package list or a key, substituting it is straightforward and leaves nothing for the script to notice.",
4976
+ cwe: "CWE-319",
4977
+ severity: "high",
4978
+ languages: ["shell"],
4979
+ pattern: /\b(?:curl|wget)\b[^\n|]*\bhttp:\/\//,
4980
+ // Loopback and link-local are not carried over a network anyone can sit on.
4981
+ lineGuard: /http:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|169\.254\.|host\.docker\.internal)\b/
4982
+ },
4983
+ {
4984
+ id: "sh-world-writable-permissions",
4985
+ title: "world-writable permissions",
4986
+ consequence: "Any local account can rewrite the file. If it is a script, a config or anything on a privileged path, the next process to read it runs someone else\u2019s content.",
4987
+ cwe: "CWE-732",
4988
+ severity: "medium",
4989
+ languages: ["shell"],
4990
+ pattern: /\bchmod\s+(?:-[a-zA-Z-]+\s+)*(?:0?777|a\+rwx|ugo\+rwx|a=rwx)\b/
4991
+ },
4992
+ {
4993
+ id: "sh-predictable-temp-path",
4994
+ title: "predictable temporary file",
4995
+ consequence: "The name is guessable, so a local attacker can create it first \u2014 as a symlink to somewhere that matters \u2014 and the script writes through it with its own privileges.",
4996
+ cwe: "CWE-377",
4997
+ severity: "medium",
4998
+ languages: ["shell"],
4999
+ // Redirection or an explicit write into a literal `/tmp` path. A `$$` or
5000
+ // `$RANDOM` suffix is still predictable, so it is not treated as a fix;
5001
+ // `mktemp` is, and it is the guard below.
5002
+ pattern: /(?:>{1,2}\s*|\b(?:tee|touch|cp|mv|install)\s+(?:-\S+\s+)*)\/tmp\/[\w.$-]+/,
5003
+ guard: /\bmktemp\b/,
5004
+ guardBack: 6,
5005
+ guardForward: 2
5006
+ },
5007
+ // ── PHP ──────────────────────────────────────────────────────────────────
5008
+ //
5009
+ // `php` was the second language in `ScanLanguage` that no rule targeted, for
5010
+ // the same reason `shell` was: nothing checks the two lists against each
5011
+ // other. `LANGUAGE_COVERAGE` in the tests does now.
5012
+ {
5013
+ id: "php-sql-interpolation",
5014
+ title: "SQL assembled by interpolation",
5015
+ consequence: "PHP interpolates variables inside double-quoted strings, so the value is part of the statement before the driver ever sees it. A quote in the value ends the literal and the rest is parsed as SQL.",
5016
+ cwe: "CWE-89",
5017
+ severity: "critical",
5018
+ languages: ["php"],
5019
+ // Interpolation (`"… $id …"`, `"… {$id} …"`) or concatenation onto a SQL
5020
+ // string. A prepared statement passes placeholders and binds separately,
5021
+ // so its query string contains neither.
5022
+ pattern: new RegExp(
5023
+ `\\b(?:mysqli_query|mysql_query|pg_query|->\\s*(?:query|exec|unprepared))\\s*\\([^)]*(?:"[^"\\n]*(?:${SQL_KEYWORDS})\\b[^"\\n]*(?:\\$\\w+|\\{\\$)|${SQL_STRING}\\s*\\.)`,
5024
+ "i"
5025
+ )
5026
+ },
5027
+ {
5028
+ id: "php-shell-exec-interpolation",
5029
+ title: "shell command built from a variable",
5030
+ consequence: "The string is handed to `/bin/sh`, which interprets `;`, `|` and `$(\u2026)` in whatever the variable held.",
5031
+ cwe: "CWE-78",
5032
+ severity: "critical",
5033
+ languages: ["php"],
5034
+ pattern: /\b(?:exec|system|shell_exec|passthru|popen|proc_open|pcntl_exec)\s*\(\s*(?:"[^"\n]*(?:\$\w+|\{\$)|'[^'\n]*'\s*\.|\$\w+\s*\.)/,
5035
+ // `escapeshellarg`/`escapeshellcmd` are the correct answer and are usually
5036
+ // applied inline, so a line-scoped guard is the right shape.
5037
+ lineGuard: /\bescapeshell(?:arg|cmd)\s*\(/
5038
+ },
5039
+ {
5040
+ id: "php-dynamic-code-execution",
5041
+ title: "dynamic code execution",
5042
+ consequence: "Whatever reaches this call is executed as PHP source with the privileges of the web process.",
5043
+ cwe: "CWE-95",
5044
+ severity: "critical",
5045
+ languages: ["php"],
5046
+ pattern: /\b(?:eval|assert|create_function)\s*\(\s*(?:\$\w+|"[^"\n]*(?:\$\w+|\{\$))/,
5047
+ guard: false
5048
+ },
5049
+ {
5050
+ id: "php-dynamic-file-inclusion",
5051
+ title: "include path built from a variable",
5052
+ consequence: "The included file is executed, not read. A traversal sequence reaches any file the process can open, and with a remote wrapper enabled the path need not be local at all.",
5053
+ cwe: "CWE-98",
5054
+ severity: "critical",
5055
+ languages: ["php"],
5056
+ pattern: /\b(?:include|include_once|require|require_once)\s*(?:\(\s*)?(?:\$\w+|"[^"\n]*(?:\$\w+|\{\$)|'[^'\n]*'\s*\.)/,
5057
+ // A basename-and-allow-list is the standard fix; `basename` alone still
5058
+ // leaves the extension open, but it defeats traversal, which is the part
5059
+ // this rule is about.
5060
+ lineGuard: /\bbasename\s*\(/
5061
+ },
5062
+ {
5063
+ id: "php-unserialize-untrusted",
5064
+ title: "unserialize on request data",
5065
+ consequence: "PHP object deserialisation instantiates classes and runs their magic methods. With a suitable class in scope this is remote code execution, and no `unserialize` option short of `allowed_classes => false` prevents it.",
5066
+ cwe: "CWE-502",
5067
+ severity: "critical",
5068
+ languages: ["php"],
5069
+ pattern: /\bunserialize\s*\(\s*(?:\$_(?:GET|POST|REQUEST|COOKIE)\b|\$\w+)/,
5070
+ // The key is an array key and is normally quoted: `['allowed_classes' => false]`.
5071
+ lineGuard: /["']?allowed_classes["']?\s*=>\s*(?:false|\[)/,
5072
+ needsContext: true
5073
+ },
5074
+ {
5075
+ id: "php-unescaped-output",
5076
+ title: "request data echoed without escaping",
5077
+ consequence: "The value is written into the response verbatim, so markup in it becomes markup in the page \u2014 script that runs with the victim\u2019s session.",
5078
+ cwe: "CWE-79",
5079
+ severity: "high",
5080
+ languages: ["php"],
5081
+ pattern: /\b(?:echo|print)\s+[^;\n]*\$_(?:GET|POST|REQUEST|COOKIE|SERVER)\b|<\?=\s*\$_(?:GET|POST|REQUEST|COOKIE)\b/
5082
+ },
5083
+ {
5084
+ id: "php-request-path-traversal",
5085
+ title: "file path taken from the request",
5086
+ consequence: "A `../` sequence in the value walks out of the intended directory, and the process reads or writes wherever it lands.",
5087
+ cwe: "CWE-22",
5088
+ severity: "high",
5089
+ languages: ["php"],
5090
+ pattern: /\b(?:file_get_contents|file_put_contents|fopen|readfile|unlink|copy|rename|opendir|scandir)\s*\(\s*[^)\n]*\$_(?:GET|POST|REQUEST|COOKIE)\b/,
5091
+ lineGuard: /\bbasename\s*\(|\brealpath\s*\(/
5092
+ },
5093
+ {
5094
+ id: "php-variable-injection",
5095
+ title: "request data expanded into local variables",
5096
+ consequence: "`extract` creates a variable per key, so a request can overwrite any local already in scope \u2014 including the one a subsequent authorisation check reads.",
5097
+ cwe: "CWE-621",
5098
+ severity: "high",
5099
+ languages: ["php"],
5100
+ pattern: /\bextract\s*\(\s*\$_(?:GET|POST|REQUEST|COOKIE)\b|\bimport_request_variables\s*\(/,
5101
+ guard: false
4876
5102
  }
4877
5103
  ];
4878
5104
  var COMMENT_PREFIX = /^\s*(?:\/\/|\/\*|\*|#|--|<!--)/;
@@ -4914,11 +5140,21 @@ function windowText(lines, index, back, forward, prose) {
4914
5140
  }
4915
5141
  return collected.join("\n");
4916
5142
  }
5143
+ var FILE_TEXT = /* @__PURE__ */ new WeakMap();
5144
+ function fileTextOf(lines) {
5145
+ let text = FILE_TEXT.get(lines);
5146
+ if (text === void 0) {
5147
+ text = lines.join("\n");
5148
+ FILE_TEXT.set(lines, text);
5149
+ }
5150
+ return text;
5151
+ }
4917
5152
  function evaluateRule(rule, ctx) {
4918
5153
  if (rule.languages && !rule.languages.includes(ctx.language)) return null;
4919
5154
  const line = ctx.lines[ctx.index] ?? "";
4920
5155
  if (isComment(line) || ctx.prose?.has(ctx.index)) return null;
4921
5156
  if (!rule.pattern.test(line)) return null;
5157
+ if (rule.fileRequires && !rule.fileRequires.test(fileTextOf(ctx.lines))) return null;
4922
5158
  const back = rule.guardBack ?? 8;
4923
5159
  const forward = rule.guardForward ?? 0;
4924
5160
  const context = windowText(ctx.lines, ctx.index, back, forward, ctx.prose);
@@ -5089,7 +5325,8 @@ function editDistance(a, b, cap = 3) {
5089
5325
  }
5090
5326
  function detectTyposquat(name, ecosystem) {
5091
5327
  const popular = ecosystem === "npm" ? POPULAR_NPM : POPULAR_PYPI;
5092
- const lower = name.toLowerCase().replace(/^@[^/]+\//, "");
5328
+ const lower = name.toLowerCase();
5329
+ if (/^@[^/]+\//.test(lower)) return null;
5093
5330
  if (popular.includes(lower)) return null;
5094
5331
  if (lower.length < 4) return null;
5095
5332
  const normalized = normalizeName(lower);
@@ -5501,6 +5738,33 @@ function languageOf(filename) {
5501
5738
  if (filename.startsWith(".env") || filename.endsWith(".env")) return "config";
5502
5739
  return LANGUAGE_BY_EXTENSION[(0, import_node_path6.extname)(filename).toLowerCase()] ?? "other";
5503
5740
  }
5741
+ var LANGUAGE_BY_INTERPRETER = {
5742
+ sh: "shell",
5743
+ bash: "shell",
5744
+ zsh: "shell",
5745
+ dash: "shell",
5746
+ ksh: "shell",
5747
+ ash: "shell",
5748
+ python: "python",
5749
+ python2: "python",
5750
+ python3: "python",
5751
+ ruby: "ruby",
5752
+ node: "javascript",
5753
+ nodejs: "javascript",
5754
+ deno: "javascript",
5755
+ bun: "javascript",
5756
+ php: "php"
5757
+ };
5758
+ function languageOfShebang(firstLine) {
5759
+ const match = /^#!\s*(\S+)(?:\s+(\S+))?/.exec(firstLine);
5760
+ if (!match) return null;
5761
+ const command = (0, import_node_path6.basename)(match[1]);
5762
+ const name = command === "env" && match[2] ? (0, import_node_path6.basename)(match[2]) : command;
5763
+ const exact = LANGUAGE_BY_INTERPRETER[name];
5764
+ if (exact) return exact;
5765
+ const stripped = name.replace(/[\d.]+$/, "");
5766
+ return (stripped ? LANGUAGE_BY_INTERPRETER[stripped] : void 0) ?? null;
5767
+ }
5504
5768
  var SUPPRESS_NEXT = /threatcrush-disable-next-line(?:\s+([\w-]+))?/;
5505
5769
  var SUPPRESS_LINE = /threatcrush-disable-line(?:\s+([\w-]+))?/;
5506
5770
  function collectSuppressions(lines) {
@@ -5617,12 +5881,14 @@ function scanPath(targetPath, options = {}) {
5617
5881
  const extension = (0, import_node_path6.extname)(filename).toLowerCase();
5618
5882
  const isManifest = filename === "package.json" || filename === "requirements.txt";
5619
5883
  const scannable = SCAN_EXTENSIONS.has(extension) || filename.startsWith(".env");
5620
- if (!scannable && !isManifest) {
5884
+ const mayDeclareInterpreter = !scannable && !isManifest && extension === "";
5885
+ if (!scannable && !isManifest && !mayDeclareInterpreter) {
5621
5886
  recordSensitiveFile(filename, relativePath, findings, []);
5622
5887
  return;
5623
5888
  }
5624
5889
  let text;
5625
5890
  let handle;
5891
+ let declared = null;
5626
5892
  try {
5627
5893
  handle = (0, import_node_fs11.openSync)(fullPath, "r");
5628
5894
  } catch {
@@ -5631,6 +5897,12 @@ function scanPath(targetPath, options = {}) {
5631
5897
  }
5632
5898
  try {
5633
5899
  if ((0, import_node_fs11.fstatSync)(handle).size > maxFileBytes) return;
5900
+ if (mayDeclareInterpreter) {
5901
+ const prefix = Buffer.alloc(128);
5902
+ const read = (0, import_node_fs11.readSync)(handle, prefix, 0, prefix.length, 0);
5903
+ declared = languageOfShebang(prefix.subarray(0, read).toString("utf-8").split("\n", 1)[0] ?? "");
5904
+ if (!declared) return;
5905
+ }
5634
5906
  text = (0, import_node_fs11.readFileSync)(handle, "utf-8");
5635
5907
  } catch {
5636
5908
  unreadable.push(relativePath);
@@ -5645,7 +5917,7 @@ function scanPath(targetPath, options = {}) {
5645
5917
  options.onFile?.(relativePath);
5646
5918
  suppressed += collectSuppressions(text.split("\n")).count;
5647
5919
  const fileFindings = [
5648
- ...scanText(relativePath, text, languageOf(filename)),
5920
+ ...scanText(relativePath, text, declared ?? languageOf(filename)),
5649
5921
  ...isManifest ? scanManifest(relativePath, filename, text) : []
5650
5922
  ];
5651
5923
  findings.push(...fileFindings);