@profullstack/threatcrush 0.4.0 → 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/index.js CHANGED
@@ -9701,8 +9701,14 @@ var UNTRUSTED_PY = /\brequest\b|\bparams\b|\bflask\b|\bsys\.argv\b|\bos\.environ
9701
9701
  var UNTRUSTED_RB = /\bparams\s*\[|\brequest\b|\bcookies\s*\[/;
9702
9702
  var UNTRUSTED_GO = /\br\s*\.\s*(?:URL|Form|Body|Header|PostForm)\b|\bFormValue\s*\(|\bQuery\s*\(\s*\)\s*\.\s*Get\s*\(|\bmux\.Vars\s*\(/;
9703
9703
  var UNTRUSTED_JAVA = /\bgetParameter\s*\(|\bgetQueryString\s*\(|\bgetHeader\s*\(|\bgetInputStream\s*\(|\bgetCookies\s*\(|\b@RequestParam\b|\b@PathVariable\b/;
9704
+ var UNTRUSTED_SH = /\$\{?[1-9]\d*\b|\$[@*]|\$\{@\}|\bread\s+(?:-\S+\s+)*[A-Za-z_]\w*|\$\{?REPLY\b|\$\{?QUERY_STRING\b/;
9705
+ var UNTRUSTED_PHP = /\$_(?:GET|POST|REQUEST|COOKIE|FILES|SERVER)\b|\bphp:\/\/input\b|\bgetallheaders\s*\(/;
9704
9706
  function untrustedPatternFor(language) {
9705
9707
  switch (language) {
9708
+ case "php":
9709
+ return UNTRUSTED_PHP;
9710
+ case "shell":
9711
+ return UNTRUSTED_SH;
9706
9712
  case "python":
9707
9713
  return UNTRUSTED_PY;
9708
9714
  case "ruby":
@@ -9727,6 +9733,7 @@ var GENERIC_GUARD = (
9727
9733
  /\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
9728
9734
  );
9729
9735
  var XXE_GUARD = /FEATURE_SECURE_PROCESSING|setExpandEntityReferences|disallow-doctype-decl|external-general-entities|external-parameter-entities|setXIncludeAware\s*\(\s*false/;
9736
+ 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/;
9730
9737
  var CODE_SINK = /\bglobalThis\s*\[|\bconstructor\b|\beval\b|\bFunction\b|\brun\s*\(|\bvm\s*\.\s*run/;
9731
9738
  var EXFIL_SINK = /\bconsole\s*\.\s*(?:log|debug|info|warn|error)\s*\(|\bfetch\s*\(|\baxios\b|\brequest\s*\(|\.\s*send\s*\(/;
9732
9739
  var SQL_KEYWORDS = "SELECT|INSERT\\s+INTO|INSERT|UPDATE|DELETE\\s+FROM|DELETE|DROP|UNION\\s+SELECT";
@@ -10001,6 +10008,16 @@ var CODE_RULES = [
10001
10008
  pattern: /\byaml\s*\.\s*load\s*\((?![^)]*safe)|\bloadAll\s*\([^)]*unsafe/i
10002
10009
  },
10003
10010
  // ── XML external entities ────────────────────────────────────────────────
10011
+ //
10012
+ // Evidence that a file parses XML at all. The XXE rules match on receiver
10013
+ // *names* — `builder.parse(is)` is the shape the vulnerability actually takes,
10014
+ // and the declared type is rarely on that line — so without this the pattern
10015
+ // reads any `.parse()` on anything suffixed Builder, Parser or Reader as XML.
10016
+ // In practice that meant a hostname-mask parser (`HostMask.Parser.parse(...)`)
10017
+ // was reported as CWE-611 at high severity.
10018
+ //
10019
+ // An import is the cheapest honest signal: a file that parses XML says so at
10020
+ // the top, and one that never mentions XML is not parsing it.
10004
10021
  {
10005
10022
  id: "java-xxe-parser-defaults",
10006
10023
  title: "XML parser left on its insecure defaults",
@@ -10021,7 +10038,10 @@ var CODE_RULES = [
10021
10038
  severity: "high",
10022
10039
  languages: ["java"],
10023
10040
  // Receiver-qualified so `LocalDate.parse(s)` and friends stay out of it.
10041
+ // The suffix alone is not enough — plenty of parsers parse things that are
10042
+ // not XML — so `fileRequires` decides whether the file is in scope at all.
10024
10043
  pattern: /\b\w*(?:[Bb]uilder|[Pp]arser|[Rr]eader)\s*\.\s*parse\s*\(/,
10044
+ fileRequires: XML_PARSING_FILE,
10025
10045
  guard: XXE_GUARD,
10026
10046
  guardBack: 6,
10027
10047
  guardForward: 4
@@ -10143,6 +10163,212 @@ var CODE_RULES = [
10143
10163
  severity: "high",
10144
10164
  languages: ["javascript", "typescript"],
10145
10165
  pattern: /\[\s*['"]__proto__['"]\s*\]|\bObject\s*\.\s*assign\s*\(\s*[\w.$]*\.prototype\b|\.\s*__proto__\s*=/
10166
+ },
10167
+ // ── Shell ────────────────────────────────────────────────────────────────
10168
+ //
10169
+ // `shell` was a language the type system knew about and no rule targeted, so
10170
+ // a repository written entirely in bash got secret detection and nothing
10171
+ // else. Installers, CI helpers and packaging scripts are where a great deal
10172
+ // of privileged work actually happens, and they run as whoever invoked them.
10173
+ {
10174
+ id: "sh-remote-script-execution",
10175
+ title: "network output piped into a shell",
10176
+ 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.",
10177
+ cwe: "CWE-494",
10178
+ severity: "high",
10179
+ languages: ["shell"],
10180
+ // The pipe must be the *next* thing: `curl -o f url && sh f` is a different
10181
+ // (and checkable) shape, and `curl url | jq` is not an execution at all.
10182
+ pattern: /\b(?:curl|wget)\b[^|\n]*\|\s*(?:sudo\s+(?:-\S+\s+)*)?(?:\/bin\/|\/usr\/bin\/)?(?:ba|da|k|z|a)?sh\b/,
10183
+ // Nothing on this line can exonerate it. Integrity checking happens in a
10184
+ // separate step by construction, so a window guard would only mislead.
10185
+ guard: false
10186
+ },
10187
+ {
10188
+ id: "sh-eval-expansion",
10189
+ title: "eval on an expanded string",
10190
+ 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.",
10191
+ cwe: "CWE-78",
10192
+ severity: "high",
10193
+ languages: ["shell"],
10194
+ // Matched against eval's *argument*, not against the rest of the line.
10195
+ //
10196
+ // `\beval\b.*\$` looks equivalent and is not: bash's ordinary dynamic-range
10197
+ // idiom, `eval echo {$((k + 1))..$((k + n))}`, contains `$k` inside the
10198
+ // arithmetic, so a line-wide search finds an expansion in a construct that
10199
+ // cannot carry a command — `$((…))` is parsed as an expression, where a `;`
10200
+ // is a syntax error rather than a second command. That spelling reported
10201
+ // 355 findings in one 3,500-line script, all of them the same safe loop.
10202
+ //
10203
+ // What is left is the form where eval is handed a value directly —
10204
+ // `eval "$cmd"`, `eval $cmd`, `eval "$(…)"` — which is the shape that
10205
+ // actually re-parses untrusted text as source. `eval echo $x` is not
10206
+ // covered; catching it without also catching the range idiom needs to know
10207
+ // which expansions are arithmetic, which is parsing, not matching.
10208
+ pattern: /\beval\s+(?:-\S+\s+)*(?:"\s*)?\$(?:\{?[A-Za-z_]\w*|\((?!\())/,
10209
+ // The shell-init idiom `eval "$(tool init -)"` is the documented interface
10210
+ // of most version managers. It is still eval of program output, but the
10211
+ // program is a fixed local binary, and flagging it reports every developer
10212
+ // dotfile in existence.
10213
+ 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/
10214
+ },
10215
+ {
10216
+ id: "sh-unquoted-expansion-destructive",
10217
+ title: "unquoted expansion in a destructive command",
10218
+ 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 /`.",
10219
+ cwe: "CWE-78",
10220
+ severity: "high",
10221
+ languages: ["shell"],
10222
+ // `[^"'\n]*?` cannot cross a quote, so `rm -rf "$dir"` — the correct form —
10223
+ // never reaches the `$` and never matches. Only a genuinely bare expansion
10224
+ // does. Restricted to recursive/forced removal: a bare `$f` in `rm $f` is
10225
+ // sloppy, but it is not the shape that erases a filesystem.
10226
+ pattern: /\brm\s+(?:-[a-zA-Z-]*[rRf][a-zA-Z-]*\s+)+[^"'\n]*?\$\{?[A-Za-z_]/
10227
+ },
10228
+ {
10229
+ id: "sh-insecure-transport-flag",
10230
+ title: "certificate verification disabled",
10231
+ 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.",
10232
+ cwe: "CWE-295",
10233
+ severity: "high",
10234
+ languages: ["shell"],
10235
+ pattern: /\b(?:curl|wget)\b[^\n|]*(?:\s-k(?=\s|$)|\s--insecure\b|\s--no-check-certificate\b)/,
10236
+ // Over plain HTTP there is no certificate to skip, so the flag is inert and
10237
+ // this rule has nothing to say — `sh-plaintext-download` is the finding
10238
+ // that fits. Reporting both put two entries on one line, one of which
10239
+ // recommended a fix that would change nothing.
10240
+ lineGuard: /^(?!.*https:\/\/).*\bhttp:\/\//
10241
+ },
10242
+ {
10243
+ id: "sh-plaintext-download",
10244
+ title: "download over plain HTTP",
10245
+ 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.",
10246
+ cwe: "CWE-319",
10247
+ severity: "high",
10248
+ languages: ["shell"],
10249
+ pattern: /\b(?:curl|wget)\b[^\n|]*\bhttp:\/\//,
10250
+ // Loopback and link-local are not carried over a network anyone can sit on.
10251
+ lineGuard: /http:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|169\.254\.|host\.docker\.internal)\b/
10252
+ },
10253
+ {
10254
+ id: "sh-world-writable-permissions",
10255
+ title: "world-writable permissions",
10256
+ 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.",
10257
+ cwe: "CWE-732",
10258
+ severity: "medium",
10259
+ languages: ["shell"],
10260
+ pattern: /\bchmod\s+(?:-[a-zA-Z-]+\s+)*(?:0?777|a\+rwx|ugo\+rwx|a=rwx)\b/
10261
+ },
10262
+ {
10263
+ id: "sh-predictable-temp-path",
10264
+ title: "predictable temporary file",
10265
+ 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.",
10266
+ cwe: "CWE-377",
10267
+ severity: "medium",
10268
+ languages: ["shell"],
10269
+ // Redirection or an explicit write into a literal `/tmp` path. A `$$` or
10270
+ // `$RANDOM` suffix is still predictable, so it is not treated as a fix;
10271
+ // `mktemp` is, and it is the guard below.
10272
+ pattern: /(?:>{1,2}\s*|\b(?:tee|touch|cp|mv|install)\s+(?:-\S+\s+)*)\/tmp\/[\w.$-]+/,
10273
+ guard: /\bmktemp\b/,
10274
+ guardBack: 6,
10275
+ guardForward: 2
10276
+ },
10277
+ // ── PHP ──────────────────────────────────────────────────────────────────
10278
+ //
10279
+ // `php` was the second language in `ScanLanguage` that no rule targeted, for
10280
+ // the same reason `shell` was: nothing checks the two lists against each
10281
+ // other. `LANGUAGE_COVERAGE` in the tests does now.
10282
+ {
10283
+ id: "php-sql-interpolation",
10284
+ title: "SQL assembled by interpolation",
10285
+ 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.",
10286
+ cwe: "CWE-89",
10287
+ severity: "critical",
10288
+ languages: ["php"],
10289
+ // Interpolation (`"… $id …"`, `"… {$id} …"`) or concatenation onto a SQL
10290
+ // string. A prepared statement passes placeholders and binds separately,
10291
+ // so its query string contains neither.
10292
+ pattern: new RegExp(
10293
+ `\\b(?:mysqli_query|mysql_query|pg_query|->\\s*(?:query|exec|unprepared))\\s*\\([^)]*(?:"[^"\\n]*(?:${SQL_KEYWORDS})\\b[^"\\n]*(?:\\$\\w+|\\{\\$)|${SQL_STRING}\\s*\\.)`,
10294
+ "i"
10295
+ )
10296
+ },
10297
+ {
10298
+ id: "php-shell-exec-interpolation",
10299
+ title: "shell command built from a variable",
10300
+ consequence: "The string is handed to `/bin/sh`, which interprets `;`, `|` and `$(\u2026)` in whatever the variable held.",
10301
+ cwe: "CWE-78",
10302
+ severity: "critical",
10303
+ languages: ["php"],
10304
+ pattern: /\b(?:exec|system|shell_exec|passthru|popen|proc_open|pcntl_exec)\s*\(\s*(?:"[^"\n]*(?:\$\w+|\{\$)|'[^'\n]*'\s*\.|\$\w+\s*\.)/,
10305
+ // `escapeshellarg`/`escapeshellcmd` are the correct answer and are usually
10306
+ // applied inline, so a line-scoped guard is the right shape.
10307
+ lineGuard: /\bescapeshell(?:arg|cmd)\s*\(/
10308
+ },
10309
+ {
10310
+ id: "php-dynamic-code-execution",
10311
+ title: "dynamic code execution",
10312
+ consequence: "Whatever reaches this call is executed as PHP source with the privileges of the web process.",
10313
+ cwe: "CWE-95",
10314
+ severity: "critical",
10315
+ languages: ["php"],
10316
+ pattern: /\b(?:eval|assert|create_function)\s*\(\s*(?:\$\w+|"[^"\n]*(?:\$\w+|\{\$))/,
10317
+ guard: false
10318
+ },
10319
+ {
10320
+ id: "php-dynamic-file-inclusion",
10321
+ title: "include path built from a variable",
10322
+ 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.",
10323
+ cwe: "CWE-98",
10324
+ severity: "critical",
10325
+ languages: ["php"],
10326
+ pattern: /\b(?:include|include_once|require|require_once)\s*(?:\(\s*)?(?:\$\w+|"[^"\n]*(?:\$\w+|\{\$)|'[^'\n]*'\s*\.)/,
10327
+ // A basename-and-allow-list is the standard fix; `basename` alone still
10328
+ // leaves the extension open, but it defeats traversal, which is the part
10329
+ // this rule is about.
10330
+ lineGuard: /\bbasename\s*\(/
10331
+ },
10332
+ {
10333
+ id: "php-unserialize-untrusted",
10334
+ title: "unserialize on request data",
10335
+ 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.",
10336
+ cwe: "CWE-502",
10337
+ severity: "critical",
10338
+ languages: ["php"],
10339
+ pattern: /\bunserialize\s*\(\s*(?:\$_(?:GET|POST|REQUEST|COOKIE)\b|\$\w+)/,
10340
+ // The key is an array key and is normally quoted: `['allowed_classes' => false]`.
10341
+ lineGuard: /["']?allowed_classes["']?\s*=>\s*(?:false|\[)/,
10342
+ needsContext: true
10343
+ },
10344
+ {
10345
+ id: "php-unescaped-output",
10346
+ title: "request data echoed without escaping",
10347
+ 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.",
10348
+ cwe: "CWE-79",
10349
+ severity: "high",
10350
+ languages: ["php"],
10351
+ pattern: /\b(?:echo|print)\s+[^;\n]*\$_(?:GET|POST|REQUEST|COOKIE|SERVER)\b|<\?=\s*\$_(?:GET|POST|REQUEST|COOKIE)\b/
10352
+ },
10353
+ {
10354
+ id: "php-request-path-traversal",
10355
+ title: "file path taken from the request",
10356
+ consequence: "A `../` sequence in the value walks out of the intended directory, and the process reads or writes wherever it lands.",
10357
+ cwe: "CWE-22",
10358
+ severity: "high",
10359
+ languages: ["php"],
10360
+ pattern: /\b(?:file_get_contents|file_put_contents|fopen|readfile|unlink|copy|rename|opendir|scandir)\s*\(\s*[^)\n]*\$_(?:GET|POST|REQUEST|COOKIE)\b/,
10361
+ lineGuard: /\bbasename\s*\(|\brealpath\s*\(/
10362
+ },
10363
+ {
10364
+ id: "php-variable-injection",
10365
+ title: "request data expanded into local variables",
10366
+ 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.",
10367
+ cwe: "CWE-621",
10368
+ severity: "high",
10369
+ languages: ["php"],
10370
+ pattern: /\bextract\s*\(\s*\$_(?:GET|POST|REQUEST|COOKIE)\b|\bimport_request_variables\s*\(/,
10371
+ guard: false
10146
10372
  }
10147
10373
  ];
10148
10374
  var COMMENT_PREFIX = /^\s*(?:\/\/|\/\*|\*|#|--|<!--)/;
@@ -10184,11 +10410,21 @@ function windowText(lines, index, back, forward, prose) {
10184
10410
  }
10185
10411
  return collected.join("\n");
10186
10412
  }
10413
+ var FILE_TEXT = /* @__PURE__ */ new WeakMap();
10414
+ function fileTextOf(lines) {
10415
+ let text = FILE_TEXT.get(lines);
10416
+ if (text === void 0) {
10417
+ text = lines.join("\n");
10418
+ FILE_TEXT.set(lines, text);
10419
+ }
10420
+ return text;
10421
+ }
10187
10422
  function evaluateRule(rule, ctx) {
10188
10423
  if (rule.languages && !rule.languages.includes(ctx.language)) return null;
10189
10424
  const line = ctx.lines[ctx.index] ?? "";
10190
10425
  if (isComment(line) || ctx.prose?.has(ctx.index)) return null;
10191
10426
  if (!rule.pattern.test(line)) return null;
10427
+ if (rule.fileRequires && !rule.fileRequires.test(fileTextOf(ctx.lines))) return null;
10192
10428
  const back = rule.guardBack ?? 8;
10193
10429
  const forward = rule.guardForward ?? 0;
10194
10430
  const context = windowText(ctx.lines, ctx.index, back, forward, ctx.prose);
@@ -10359,7 +10595,8 @@ function editDistance(a, b, cap = 3) {
10359
10595
  }
10360
10596
  function detectTyposquat(name, ecosystem) {
10361
10597
  const popular = ecosystem === "npm" ? POPULAR_NPM : POPULAR_PYPI;
10362
- const lower = name.toLowerCase().replace(/^@[^/]+\//, "");
10598
+ const lower = name.toLowerCase();
10599
+ if (/^@[^/]+\//.test(lower)) return null;
10363
10600
  if (popular.includes(lower)) return null;
10364
10601
  if (lower.length < 4) return null;
10365
10602
  const normalized = normalizeName(lower);
@@ -10771,6 +11008,33 @@ function languageOf(filename) {
10771
11008
  if (filename.startsWith(".env") || filename.endsWith(".env")) return "config";
10772
11009
  return LANGUAGE_BY_EXTENSION[(0, import_node_path3.extname)(filename).toLowerCase()] ?? "other";
10773
11010
  }
11011
+ var LANGUAGE_BY_INTERPRETER = {
11012
+ sh: "shell",
11013
+ bash: "shell",
11014
+ zsh: "shell",
11015
+ dash: "shell",
11016
+ ksh: "shell",
11017
+ ash: "shell",
11018
+ python: "python",
11019
+ python2: "python",
11020
+ python3: "python",
11021
+ ruby: "ruby",
11022
+ node: "javascript",
11023
+ nodejs: "javascript",
11024
+ deno: "javascript",
11025
+ bun: "javascript",
11026
+ php: "php"
11027
+ };
11028
+ function languageOfShebang(firstLine) {
11029
+ const match = /^#!\s*(\S+)(?:\s+(\S+))?/.exec(firstLine);
11030
+ if (!match) return null;
11031
+ const command = (0, import_node_path3.basename)(match[1]);
11032
+ const name = command === "env" && match[2] ? (0, import_node_path3.basename)(match[2]) : command;
11033
+ const exact = LANGUAGE_BY_INTERPRETER[name];
11034
+ if (exact) return exact;
11035
+ const stripped = name.replace(/[\d.]+$/, "");
11036
+ return (stripped ? LANGUAGE_BY_INTERPRETER[stripped] : void 0) ?? null;
11037
+ }
10774
11038
  var SUPPRESS_NEXT = /threatcrush-disable-next-line(?:\s+([\w-]+))?/;
10775
11039
  var SUPPRESS_LINE = /threatcrush-disable-line(?:\s+([\w-]+))?/;
10776
11040
  function collectSuppressions(lines) {
@@ -10887,12 +11151,14 @@ function scanPath(targetPath, options = {}) {
10887
11151
  const extension = (0, import_node_path3.extname)(filename).toLowerCase();
10888
11152
  const isManifest = filename === "package.json" || filename === "requirements.txt";
10889
11153
  const scannable = SCAN_EXTENSIONS.has(extension) || filename.startsWith(".env");
10890
- if (!scannable && !isManifest) {
11154
+ const mayDeclareInterpreter = !scannable && !isManifest && extension === "";
11155
+ if (!scannable && !isManifest && !mayDeclareInterpreter) {
10891
11156
  recordSensitiveFile(filename, relativePath, findings, []);
10892
11157
  return;
10893
11158
  }
10894
11159
  let text;
10895
11160
  let handle;
11161
+ let declared = null;
10896
11162
  try {
10897
11163
  handle = (0, import_node_fs6.openSync)(fullPath, "r");
10898
11164
  } catch {
@@ -10901,6 +11167,12 @@ function scanPath(targetPath, options = {}) {
10901
11167
  }
10902
11168
  try {
10903
11169
  if ((0, import_node_fs6.fstatSync)(handle).size > maxFileBytes) return;
11170
+ if (mayDeclareInterpreter) {
11171
+ const prefix = Buffer.alloc(128);
11172
+ const read = (0, import_node_fs6.readSync)(handle, prefix, 0, prefix.length, 0);
11173
+ declared = languageOfShebang(prefix.subarray(0, read).toString("utf-8").split("\n", 1)[0] ?? "");
11174
+ if (!declared) return;
11175
+ }
10904
11176
  text = (0, import_node_fs6.readFileSync)(handle, "utf-8");
10905
11177
  } catch {
10906
11178
  unreadable.push(relativePath);
@@ -10915,7 +11187,7 @@ function scanPath(targetPath, options = {}) {
10915
11187
  options.onFile?.(relativePath);
10916
11188
  suppressed += collectSuppressions(text.split("\n")).count;
10917
11189
  const fileFindings = [
10918
- ...scanText(relativePath, text, languageOf(filename)),
11190
+ ...scanText(relativePath, text, declared ?? languageOf(filename)),
10919
11191
  ...isManifest ? scanManifest(relativePath, filename, text) : []
10920
11192
  ];
10921
11193
  findings.push(...fileFindings);