From 68f15e340933c9de2e1f5a9a70fe0df6391e695d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 08:54:29 +0000 Subject: [PATCH 1/6] fix(code_safety): js_exec_sync flags only calls a shell parses The pattern matched the bare word child_process, so importing spawnSync from node:child_process was a High CWE-78 finding (mathesis#90), while exec( and shell: true were missed. It now matches execSync(, exec(, a .exec( given a template string, and a shell: option set to true or a shell path. spawn/spawnSync/execFile/execFileSync with an argv array and no shell option are not findings. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0136P7ZkGCWGEyedTtdEHAPT --- lib/rules/code_safety.ex | 10 +++++++++- test/code_safety_test.exs | 40 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/lib/rules/code_safety.ex b/lib/rules/code_safety.ex index bdc66b9e..2acd0709 100644 --- a/lib/rules/code_safety.ex +++ b/lib/rules/code_safety.ex @@ -608,7 +608,15 @@ defmodule Hypatia.Rules.CodeSafety do %{ id: :js_exec_sync, severity: :high, - pattern: ~r/execSync\s*\(|child_process/, + # Only calls a shell parses: execSync( and exec( (always /bin/sh -c), + # a `.exec(` given a template string, and any call with a `shell:` + # option set to true or a shell path. Importing node:child_process is + # not a finding, nor is spawn/spawnSync/execFile/execFileSync with an + # argv array and no `shell:` option: no shell sees the arguments. + # A pattern cannot tell where a program or argument came from, so + # tainted argv to a shell-free call is out of reach here (mathesis#90). + pattern: + ~r/\bexecSync\s*\(|(? Date: Sat, 14 Nov 2026 09:01:29 +0000 Subject: [PATCH 2/6] fix(code_safety): js_exec_sync review points (quoted keys, bound names) - A quoted shell key ('shell': true, "shell": true) now matches. - A shell key matches only inside a spawn/spawnSync/execFile/ execFileSync/fork call; an unrelated {shell: true} object does not. - .exec / .execSync match through the name the file binds node:child_process to, with any argument shape (cp.exec('rm ' + x)). The finding is on the call's line (\K), not the import's. - Drop the .exec( + template-string heuristic: it flagged RegExp.exec. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0136P7ZkGCWGEyedTtdEHAPT --- lib/rules/code_safety.ex | 21 +++++++++++++-------- test/code_safety_test.exs | 23 ++++++++++++++++++++++- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/lib/rules/code_safety.ex b/lib/rules/code_safety.ex index 2acd0709..290140ca 100644 --- a/lib/rules/code_safety.ex +++ b/lib/rules/code_safety.ex @@ -608,15 +608,20 @@ defmodule Hypatia.Rules.CodeSafety do %{ id: :js_exec_sync, severity: :high, - # Only calls a shell parses: execSync( and exec( (always /bin/sh -c), - # a `.exec(` given a template string, and any call with a `shell:` - # option set to true or a shell path. Importing node:child_process is - # not a finding, nor is spawn/spawnSync/execFile/execFileSync with an - # argv array and no `shell:` option: no shell sees the arguments. - # A pattern cannot tell where a program or argument came from, so - # tainted argv to a shell-free call is out of reach here (mathesis#90). + # Only calls a shell parses: + # * execSync( and a bare exec( (always /bin/sh -c); + # * .exec( / .execSync( where is the name the file binds + # node:child_process to (import * as ns / import ns / require), and + # require('child_process').exec( -- any argument shape; + # * spawn/spawnSync/execFile/execFileSync/fork called with a `shell` + # option (bare or quoted key) set to true or to a shell path. + # Importing node:child_process is not a finding, nor is a shell-free + # call with an argv array, RegExp.exec, or a `shell` key outside a + # process call. \K reports the call's line, not the import's. + # Not covered: a shell option passed through a variable, and tainted + # argv to a shell-free call -- a pattern cannot trace values (mathesis#90). pattern: - ~r/\bexecSync\s*\(|(? Date: Sat, 14 Nov 2026 09:06:35 +0000 Subject: [PATCH 3/6] fix(code_safety): js_exec_sync counts every call through a bound name One regex carried the child_process binding to the first .exec call only (the match consumed it), so a second cp.exec was not counted and its line was not reported. pattern_for/2 now reads the names the file binds node:child_process to and widens the rule's pattern with .exec( / .execSync( per file, in scan_content/2 and scan_web_security/1. Tests: two calls report two occurrences on lines 2 and 3; two bindings in one file are both followed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0136P7ZkGCWGEyedTtdEHAPT --- lib/rules/code_safety.ex | 40 +++++++++++++++++++++++++++++++++++---- test/code_safety_test.exs | 19 +++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/lib/rules/code_safety.ex b/lib/rules/code_safety.ex index 290140ca..792eaba7 100644 --- a/lib/rules/code_safety.ex +++ b/lib/rules/code_safety.ex @@ -617,11 +617,13 @@ defmodule Hypatia.Rules.CodeSafety do # option (bare or quoted key) set to true or to a shell path. # Importing node:child_process is not a finding, nor is a shell-free # call with an argv array, RegExp.exec, or a `shell` key outside a - # process call. \K reports the call's line, not the import's. + # process call. The calls are matched per file by + # pattern_for/2 (`bound_calls`), so every call counts, on its own line. # Not covered: a shell option passed through a variable, and tainted # argv to a shell-free call -- a pattern cannot trace values (mathesis#90). pattern: - ~r/\bexecSync\s*\(|(?.exec(` / `.execSync(`. + @child_process_binding ~r/(?:import\s+(?:\*\s+as\s+)?([A-Za-z_$][\w$]*)\s+from\s*['"](?:node:)?child_process['"]|(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*['"](?:node:)?child_process['"]\s*\))/ + + @doc false + def pattern_for(%{bound_calls: :child_process} = rule, content) do + names = + @child_process_binding + |> Regex.scan(content, capture: :all_but_first) + |> List.flatten() + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + + case names do + [] -> + rule.pattern + + names -> + alt = Enum.map_join(names, "|", &Regex.escape/1) + + Regex.compile!( + Regex.source(rule.pattern) <> "|(? alt <> ")\\.exec(?:Sync)?\\s*\\(" + ) + end + end + + def pattern_for(rule, _content), do: rule.pattern + @doc "Scan JavaScript/TypeScript content for web security issues" def scan_web_security(content) do Enum.flat_map(@javascript_patterns, fn rule -> - case Regex.scan(rule.pattern, content) do + case Regex.scan(pattern_for(rule, content), content) do [] -> [] diff --git a/test/code_safety_test.exs b/test/code_safety_test.exs index 31dd3b08..3378b2a6 100644 --- a/test/code_safety_test.exs +++ b/test/code_safety_test.exs @@ -918,6 +918,25 @@ defmodule Hypatia.Rules.CodeSafetyTest do assert f.lines == [4] end + test "every call through a bound name counts, each on its own line" do + code = "const cp = require('child_process');\ncp.exec('first');\ncp.exec('second');\n" + [f] = Enum.filter(CodeSafety.scan_content(code, "javascript"), &(&1.rule == :js_exec_sync)) + assert f.occurrences == 2 + assert f.lines == [2, 3] + + [w] = Enum.filter(CodeSafety.scan_web_security(code), &(&1.rule == :js_exec_sync)) + assert w.occurrences == 2 + end + + test "two bindings in one file are both followed" do + code = + "import * as cp from 'node:child_process';\nconst proc = require('child_process');\n" <> + "proc.execSync(a);\ncp.exec(b);\n" + + [f] = Enum.filter(CodeSafety.scan_content(code, "javascript"), &(&1.rule == :js_exec_sync)) + assert f.lines == [3, 4] + end + test "a shell option makes spawn and execFile shell calls" do assert exec_sync?("spawnSync(cmd, args, {shell: true});\n") assert exec_sync?("spawn(cmd, { shell : '/bin/bash' });\n") From cfd7649a3ab1517b022c290cc82ceb92c17063e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 09:08:25 +0000 Subject: [PATCH 4/6] fix(code_safety): js_exec_sync matches a bare exec only when imported A bare exec( matched anywhere, so a file's own function named exec was reported once called (Codex review). exec( now matches only under a name the file imports from node:child_process: {exec}, {exec as run}, {exec: run}. Namespace calls (cp.exec) are unchanged. The regex sources for the bindings are strings compiled at call time, not compiled regexes in module attributes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0136P7ZkGCWGEyedTtdEHAPT --- lib/rules/code_safety.ex | 71 +++++++++++++++++++++++++++------------ test/code_safety_test.exs | 6 +++- 2 files changed, 55 insertions(+), 22 deletions(-) diff --git a/lib/rules/code_safety.ex b/lib/rules/code_safety.ex index 792eaba7..896d8b4d 100644 --- a/lib/rules/code_safety.ex +++ b/lib/rules/code_safety.ex @@ -609,7 +609,9 @@ defmodule Hypatia.Rules.CodeSafety do id: :js_exec_sync, severity: :high, # Only calls a shell parses: - # * execSync( and a bare exec( (always /bin/sh -c); + # * execSync(, and exec( under a name the file imports from + # node:child_process ({exec}, {exec as run}, {exec: run}) -- + # a local function named exec is not a shell; # * .exec( / .execSync( where is the name the file binds # node:child_process to (import * as ns / import ns / require), and # require('child_process').exec( -- any argument shape; @@ -622,7 +624,7 @@ defmodule Hypatia.Rules.CodeSafety do # Not covered: a shell option passed through a variable, and tainted # argv to a shell-free call -- a pattern cannot trace values (mathesis#90). pattern: - ~r/\bexecSync\s*\(|(?.exec(` / `.execSync(`. - @child_process_binding ~r/(?:import\s+(?:\*\s+as\s+)?([A-Za-z_$][\w$]*)\s+from\s*['"](?:node:)?child_process['"]|(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*['"](?:node:)?child_process['"]\s*\))/ + # A file's bindings of node:child_process. A regex cannot carry a binding + # to every later call (a match consumes it), so the rule's pattern is + # widened per file: + # * namespaces (`import * as cp from`, `import cp from`, + # `const cp = require(...)`) add `cp.exec(` and `cp.execSync(`; + # * destructured exec (`import {exec, exec as run} from`, + # `const {exec: run} = require(...)`) adds `exec(` / `run(`. + # Regex sources, compiled at call time: a compiled regex in a module + # attribute does not survive OTP 28. + @cp_source ~S|['"](?:node:)?child_process['"]| + @cp_namespace ~S|(?:import\s+(?:\*\s+as\s+)?([A-Za-z_$][\w$]*)\s+from\s*| <> + @cp_source <> + ~S/|(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*/ <> + @cp_source <> ~S|\s*\))| + @cp_destructured ~S|(?:import\s*\{([^}]*)\}\s*from\s*| <> + @cp_source <> + ~S/|(?:const|let|var)\s*\{([^}]*)\}\s*=\s*require\(\s*/ <> + @cp_source <> ~S|\s*\))| @doc false def pattern_for(%{bound_calls: :child_process} = rule, content) do - names = - @child_process_binding - |> Regex.scan(content, capture: :all_but_first) - |> List.flatten() - |> Enum.reject(&(&1 == "")) + namespaces = captures(Regex.compile!(@cp_namespace), content) + + execs = + @cp_destructured + |> Regex.compile!() + |> captures(content) + |> Enum.flat_map(&String.split(&1, ",")) + |> Enum.flat_map(fn entry -> + case Regex.run(~r/^\s*exec\s*(?:(?:as|:)\s*([A-Za-z_$][\w$]*))?\s*$/, entry) do + [_, local] -> [local] + [_] -> ["exec"] + nil -> [] + end + end) |> Enum.uniq() - case names do - [] -> - rule.pattern - - names -> - alt = Enum.map_join(names, "|", &Regex.escape/1) + extra = + Enum.map(namespaces, &("(? Regex.escape(&1) <> "\\.exec(?:Sync)?\\s*\\(")) ++ + Enum.map(execs, &("(? Regex.escape(&1) <> "\\s*\\(")) - Regex.compile!( - Regex.source(rule.pattern) <> "|(? alt <> ")\\.exec(?:Sync)?\\s*\\(" - ) + case extra do + [] -> rule.pattern + extra -> Regex.compile!(Enum.join([Regex.source(rule.pattern) | extra], "|")) end end def pattern_for(rule, _content), do: rule.pattern + defp captures(regex, content) do + regex + |> Regex.scan(content, capture: :all_but_first) + |> List.flatten() + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end + @doc "Scan JavaScript/TypeScript content for web security issues" def scan_web_security(content) do Enum.flat_map(@javascript_patterns, fn rule -> diff --git a/test/code_safety_test.exs b/test/code_safety_test.exs index 3378b2a6..18df239c 100644 --- a/test/code_safety_test.exs +++ b/test/code_safety_test.exs @@ -891,7 +891,8 @@ defmodule Hypatia.Rules.CodeSafetyTest do test "RegExp.exec and a function named exec are not findings" do refute exec_sync?("const m = /^v(\\d+)/.exec(tag);\nconst n = re.exec(line);\n") refute exec_sync?("const t = re.exec(`${prefix}-${name}`);\n") - refute exec_sync?("function exec(cmd) { return cmd; }\n") + refute exec_sync?("function exec(cmd) { return cmd; }\nexec('safe');\n") + refute exec_sync?("const exec = (c) => c;\nexec(cmd);\n") end test "a shell key outside a process call is not a finding" do @@ -902,6 +903,9 @@ defmodule Hypatia.Rules.CodeSafetyTest do test "execSync and exec run a shell" do assert exec_sync?("const out = execSync(`git log ${ref}`);\n") assert exec_sync?("import {exec} from 'node:child_process';\nexec(cmd, cb);\n") + assert exec_sync?("import {spawn, exec as run} from 'child_process';\nrun(cmd);\n") + assert exec_sync?("const { exec: sh } = require('node:child_process');\nsh(cmd);\n") + refute exec_sync?("import {spawnSync} from 'node:child_process';\nexec(cmd);\n") assert exec_sync?("require('child_process').exec(`ls ${dir}`, cb);\n") end From d76f203072cb1e6d07c7cac13243811e7914c691 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 09:09:54 +0000 Subject: [PATCH 5/6] fix(code_safety): js_exec_sync follows a renamed execSync binding {execSync as run} and {execSync: run} from node:child_process were missed: the destructured-entry match accepted only exec. It now accepts exec and execSync, renamed or not. Tests cover both renamed forms. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0136P7ZkGCWGEyedTtdEHAPT --- lib/rules/code_safety.ex | 10 +++++----- test/code_safety_test.exs | 2 ++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/rules/code_safety.ex b/lib/rules/code_safety.ex index 896d8b4d..3969910d 100644 --- a/lib/rules/code_safety.ex +++ b/lib/rules/code_safety.ex @@ -1646,8 +1646,8 @@ defmodule Hypatia.Rules.CodeSafety do # widened per file: # * namespaces (`import * as cp from`, `import cp from`, # `const cp = require(...)`) add `cp.exec(` and `cp.execSync(`; - # * destructured exec (`import {exec, exec as run} from`, - # `const {exec: run} = require(...)`) adds `exec(` / `run(`. + # * destructured exec or execSync (`import {exec, execSync as run} + # from`, `const {exec: run} = require(...)`) adds `exec(` / `run(`. # Regex sources, compiled at call time: a compiled regex in a module # attribute does not survive OTP 28. @cp_source ~S|['"](?:node:)?child_process['"]| @@ -1670,9 +1670,9 @@ defmodule Hypatia.Rules.CodeSafety do |> captures(content) |> Enum.flat_map(&String.split(&1, ",")) |> Enum.flat_map(fn entry -> - case Regex.run(~r/^\s*exec\s*(?:(?:as|:)\s*([A-Za-z_$][\w$]*))?\s*$/, entry) do - [_, local] -> [local] - [_] -> ["exec"] + case Regex.run(~r/^\s*(exec(?:Sync)?)\s*(?:(?:as|:)\s*([A-Za-z_$][\w$]*))?\s*$/, entry) do + [_, _name, local] -> [local] + [_, name] -> [name] nil -> [] end end) diff --git a/test/code_safety_test.exs b/test/code_safety_test.exs index 18df239c..80d6cc6c 100644 --- a/test/code_safety_test.exs +++ b/test/code_safety_test.exs @@ -905,6 +905,8 @@ defmodule Hypatia.Rules.CodeSafetyTest do assert exec_sync?("import {exec} from 'node:child_process';\nexec(cmd, cb);\n") assert exec_sync?("import {spawn, exec as run} from 'child_process';\nrun(cmd);\n") assert exec_sync?("const { exec: sh } = require('node:child_process');\nsh(cmd);\n") + assert exec_sync?("import {execSync as run} from 'node:child_process';\nrun(cmd);\n") + assert exec_sync?("const {execSync: run} = require('child_process');\nrun(cmd);\n") refute exec_sync?("import {spawnSync} from 'node:child_process';\nexec(cmd);\n") assert exec_sync?("require('child_process').exec(`ls ${dir}`, cb);\n") end From 60608847f1d096710eb250e8c163c0e0d69e6e20 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 09:27:06 +0000 Subject: [PATCH 6/6] fix(code_safety): js_exec_sync review of d76f203 (fork, aliases, import()) - Drop fork from the shell-option callees: fork() ignores shell. - A renamed spawn/spawnSync/execFile/execFileSync ({spawn as run}, {spawn: run}) with a shell option is a shell call, like the plain names. - A dynamic import (const cp = await import('node:child_process'), and the destructured form) binds child_process like require. - Call arguments may nest parentheses two deep before the shell option. - Known false positive noted in the rule: a shell key nested inside another option (env: {shell: ...}) is reported. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0136P7ZkGCWGEyedTtdEHAPT --- lib/rules/code_safety.ex | 57 ++++++++++++++++++++++++++++----------- test/code_safety_test.exs | 24 +++++++++++++++++ 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/lib/rules/code_safety.ex b/lib/rules/code_safety.ex index 3969910d..6ac1888b 100644 --- a/lib/rules/code_safety.ex +++ b/lib/rules/code_safety.ex @@ -613,18 +613,25 @@ defmodule Hypatia.Rules.CodeSafety do # node:child_process ({exec}, {exec as run}, {exec: run}) -- # a local function named exec is not a shell; # * .exec( / .execSync( where is the name the file binds - # node:child_process to (import * as ns / import ns / require), and + # node:child_process to (import * as ns / import ns / require / + # await import), and # require('child_process').exec( -- any argument shape; - # * spawn/spawnSync/execFile/execFileSync/fork called with a `shell` - # option (bare or quoted key) set to true or to a shell path. + # * spawn/spawnSync/execFile/execFileSync (or a name the file imports + # them as) called with a `shell` option (bare or quoted key) set to + # true or to a shell path. fork() ignores `shell`, so it is not here. # Importing node:child_process is not a finding, nor is a shell-free # call with an argv array, RegExp.exec, or a `shell` key outside a # process call. The calls are matched per file by # pattern_for/2 (`bound_calls`), so every call counts, on its own line. - # Not covered: a shell option passed through a variable, and tainted - # argv to a shell-free call -- a pattern cannot trace values (mathesis#90). + # Not covered: a shell option passed through a variable, tainted argv to + # a shell-free call, and call arguments nested more than two + # parentheses deep -- a pattern cannot trace values (mathesis#90). + # Known false positive: a `shell` key nested inside another option of a + # process call (`spawn(c, a, {env: {shell: 'x'}})`) is reported; telling + # object depth apart needs a parser, and a rare false alarm is the safer + # error for a high-severity rule. pattern: - ~r/\bexecSync\s*\(|require\(\s*['"](?:node:)?child_process['"]\s*\)\s*\.exec(?:Sync)?\s*\(|\b(?:spawn|spawnSync|execFile|execFileSync|fork)\s*\((?:[^;()]|\([^()]*\))*?['"]?\bshell['"]?\s*:\s*(?:true\b|['"`])/, + ~r/\bexecSync\s*\(|require\(\s*['"](?:node:)?child_process['"]\s*\)\s*\.exec(?:Sync)?\s*\(|\b(?:spawn|spawnSync|execFile|execFileSync)\s*\((?:[^;()]|\((?:[^()]|\([^()]*\))*\))*?['"]?\bshell['"]?\s*:\s*(?:true\b|['"`])/, bound_calls: :child_process, cwe: "CWE-78", description: "Shell execution -- validate input before passing to shell" @@ -1647,40 +1654,58 @@ defmodule Hypatia.Rules.CodeSafety do # * namespaces (`import * as cp from`, `import cp from`, # `const cp = require(...)`) add `cp.exec(` and `cp.execSync(`; # * destructured exec or execSync (`import {exec, execSync as run} - # from`, `const {exec: run} = require(...)`) adds `exec(` / `run(`. + # from`, `const {exec: run} = require(...)`) adds `exec(` / `run(`; + # * a renamed spawn/spawnSync/execFile/execFileSync (`{spawn as run}`) + # adds `run(` with a `shell` option, like the unrenamed names. + # `require(...)` may also be `import(...)` or `await import(...)`. # Regex sources, compiled at call time: a compiled regex in a module # attribute does not survive OTP 28. @cp_source ~S|['"](?:node:)?child_process['"]| + @cp_load ~S/(?:require|(?:await\s+)?import)\(\s*/ @cp_namespace ~S|(?:import\s+(?:\*\s+as\s+)?([A-Za-z_$][\w$]*)\s+from\s*| <> @cp_source <> - ~S/|(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*/ <> - @cp_source <> ~S|\s*\))| + ~S/|(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*/ <> + @cp_load <> @cp_source <> ~S|\s*\))| @cp_destructured ~S|(?:import\s*\{([^}]*)\}\s*from\s*| <> @cp_source <> - ~S/|(?:const|let|var)\s*\{([^}]*)\}\s*=\s*require\(\s*/ <> - @cp_source <> ~S|\s*\))| + ~S/|(?:const|let|var)\s*\{([^}]*)\}\s*=\s*/ <> + @cp_load <> @cp_source <> ~S|\s*\))| + # The argument span of a call up to a `shell` option set to true or to a + # shell path; arguments may nest parentheses two deep. + @shell_option_tail ~S/\s*\((?:[^;()]|\((?:[^()]|\([^()]*\))*\))*?['"]?\bshell['"]?\s*:\s*(?:true\b|['"`])/ @doc false def pattern_for(%{bound_calls: :child_process} = rule, content) do namespaces = captures(Regex.compile!(@cp_namespace), content) - execs = + bindings = @cp_destructured |> Regex.compile!() |> captures(content) |> Enum.flat_map(&String.split(&1, ",")) |> Enum.flat_map(fn entry -> - case Regex.run(~r/^\s*(exec(?:Sync)?)\s*(?:(?:as|:)\s*([A-Za-z_$][\w$]*))?\s*$/, entry) do - [_, _name, local] -> [local] - [_, name] -> [name] + case Regex.run( + ~r/^\s*(exec|execSync|spawn|spawnSync|execFile|execFileSync)\s*(?:(?:as|:)\s*([A-Za-z_$][\w$]*))?\s*$/, + entry + ) do + [_, name, local] -> [{name, local}] + [_, name] -> [{name, name}] nil -> [] end end) |> Enum.uniq() + # exec/execSync run a shell whatever the arguments; the spawn family + # does only with a `shell` option (unrenamed names are in the pattern). + execs = for {name, local} <- bindings, name in ["exec", "execSync"], do: local + + spawns = + for {name, local} <- bindings, name not in ["exec", "execSync"], local != name, do: local + extra = Enum.map(namespaces, &("(? Regex.escape(&1) <> "\\.exec(?:Sync)?\\s*\\(")) ++ - Enum.map(execs, &("(? Regex.escape(&1) <> "\\s*\\(")) + Enum.map(execs, &("(? Regex.escape(&1) <> "\\s*\\(")) ++ + Enum.map(spawns, &("(? Regex.escape(&1) <> @shell_option_tail)) case extra do [] -> rule.pattern diff --git a/test/code_safety_test.exs b/test/code_safety_test.exs index 80d6cc6c..f45d5426 100644 --- a/test/code_safety_test.exs +++ b/test/code_safety_test.exs @@ -951,6 +951,30 @@ defmodule Hypatia.Rules.CodeSafetyTest do assert exec_sync?("spawnSync(cmd, args, {\"shell\": true});\n") assert exec_sync?("spawn(path.join(dir, 'x'), [a], {\n shell: true\n});\n") refute exec_sync?("spawnSync(cmd, args, {shell: false});\n") + assert exec_sync?("spawn(resolve(path.join(dir, 'x')), args, {shell: true});\n") + end + + test "fork ignores the shell option" do + refute exec_sync?( + "import {fork} from 'node:child_process';\nfork(mod, args, {shell: true});\n" + ) + end + + test "a renamed spawn or execFile with a shell option is a shell call" do + assert exec_sync?( + "import {spawn as run} from 'node:child_process';\nrun(cmd, a, {shell: true});\n" + ) + + assert exec_sync?( + "const {execFileSync: x} = require('child_process');\nx(cmd, a, {shell: true});\n" + ) + + refute exec_sync?("import {spawn as run} from 'node:child_process';\nrun(cmd, a);\n") + end + + test "a dynamic import binds child_process too" do + assert exec_sync?("const cp = await import('node:child_process');\ncp.exec(command);\n") + assert exec_sync?("const {exec: sh} = await import('child_process');\nsh(command);\n") end end end