harden(bash): don't treat a $VAR/path command word as an interpreter

Follow-up on #4007: is_interpreter_variable fired for any bare-$VAR
command word, so `"$DIR/run.sh" stage.py` could mint a spurious invokes
edge to the argument on top of the real .sh calls edge. A bare
interpreter variable ($PYTHON) has no path separator; gate on
`"/" not in cmd_word`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
safishamsiandClaude Opus 4.8 committed 2026-10-04 02:08:21 +01:00
1 parent e402b87b73
commit 0110d962b9
2 files changed
+26 -1

No files matched your search

+7 -1
View File
@@ -531,8 +531,14 @@ def extract_bash(path: Path) -> dict:
cmd_word = text(cmd_name_node).strip()
if cmd_word[0:1] in {"'", '"'} and cmd_word[-1:] == cmd_word[0]:
cmd_word = cmd_word[1:-1]
# A bare variable standing in for an interpreter (`$PYTHON x.py`)
# has no path separator; `"$DIR/run.sh" x.py` runs run.sh with
# x.py as its ARGUMENT, so the `/` guard keeps us from minting a
# spurious invokes edge to the argument on top of the real
# `calls` edge the .sh runner path already emits.
is_interpreter_variable = (
cmd is None and cmd_word.startswith("$") and "$(" not in cmd_word
cmd is None and cmd_word.startswith("$")
and "$(" not in cmd_word and "/" not in cmd_word
)
if (is_known_interpreter or is_interpreter_variable) and args:
invoked_raw = literal(args[0])
+19
View File
@@ -3291,6 +3291,25 @@ def test_extract_bash_skips_invokes_for_a_non_interpreter_command(tmp_path):
assert not any(edge.get("relation") == "invokes" for edge in result["edges"])
def test_extract_bash_variable_path_command_does_not_invoke_its_argument(tmp_path):
"""A variable-built PATH command (`"$DIR/run.sh" stage.py`) runs run.sh with
stage.py as its argument; the `/` in the command word means it is not a bare
interpreter variable, so no spurious `invokes` edge to the argument is minted
(only the real .sh `calls` edge the runner path already emits)."""
stage = tmp_path / "stage.py"
stage.write_text("print('stage')\n", encoding="utf-8")
runner = tmp_path / "run.sh"
runner.write_text("#!/bin/bash\necho run\n", encoding="utf-8")
script = tmp_path / "caller.sh"
script.write_text('#!/bin/bash\nDIR="."\n"$DIR/run.sh" stage.py\n', encoding="utf-8")
result = extract_bash(script)
assert not any(
edge.get("relation") == "invokes" and edge.get("target") == _make_id(str(stage.resolve()))
for edge in result["edges"]
)
def test_extract_bash_skips_invokes_for_a_missing_script(tmp_path):
script = tmp_path / "runner.sh"
script.write_text("#!/bin/bash\npython3 does_not_exist.py\n", encoding="utf-8")