diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 9722303947..d0e8b2f3c8 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -332,6 +332,29 @@ _SENSITIVE_ROOT_WITH_EXPANSION_RE = re.compile( re.IGNORECASE, ) +# ``cat /etc/sha*ow`` / ``cat /etc/sh?dow`` -- bash expands ``*`` and +# ``?`` glob wildcards against the filesystem. The brace expander above +# only handles ``{a,b}`` braces; this pattern catches the wildcard +# globs that target a sensitive root path. The literal-text-only +# constraint (``[^\s'\";&|`$]*[*?]``) ensures we match an attached +# glob char and not a glob that lives in a separate argument like +# ``find /etc/ -name '*.conf'`` (whitespace breaks the token). +_SENSITIVE_ROOT_WITH_GLOB_RE = re.compile( + _PATH_TOKEN_START + + r"(?:" + + r"~(?:[^/\s'\";&|)<>]*)?/" + + r"|\$\{?HOME\}?/" + + r"|/home/[^/\s'\"]+/" + + r"|/root/" + + r"|/Users/[^/\s'\"]+/" + + r"|/etc/" + + r"|/proc/(?:self|thread-self|\d+)/" + + r"|/var/spool/" + + r")" + + r"[^\s'\";&|`$]*[*?]", + re.IGNORECASE, +) + _BRACE_EXPANSION_RE = re.compile(r"\{([^{}]*,[^{}]*)\}") @@ -504,6 +527,13 @@ def _find_sensitive_paths(command: str) -> set[str]: # cannot statically resolve (``cat /etc/$(printf shadow)``). for m in _SENSITIVE_ROOT_WITH_EXPANSION_RE.finditer(text): found.add(m.group(0)) + # Sensitive prefix + bash glob (``cat /etc/sha*ow``, + # ``cat /etc/sh?dow``, ``cat /etc/*``). The shell expands the + # glob at runtime; statically we cannot enumerate the matches + # but a glob immediately attached to a sensitive root is + # an attempt to escape literal-path detection. + for m in _SENSITIVE_ROOT_WITH_GLOB_RE.finditer(text): + found.add(m.group(0)) # Recurse into nested shells. Mirrors the structure in # _find_blocked_commands so ``bash -c "cat ~/.ssh/id_rsa"`` and @@ -1525,6 +1555,19 @@ def _check_signal_escape_patterns(code: str): if val is not None and isinstance(node.target, ast.Name): string_bindings.setdefault(node.target.id, val) return val + if isinstance(node, ast.IfExp): + # Ternary ``'/etc/shadow' if cond else 'data.txt'``: either + # branch can execute at runtime, so a sensitive value in + # ANY branch must reach the gate. Prefer the sensitive one + # so the downstream check fires; fall back to whichever + # branch resolves. + body_val = _extract_string_from_node(node.body, _depth + 1) + orelse_val = _extract_string_from_node(node.orelse, _depth + 1) + if body_val is not None and _looks_sensitive(body_val): + return body_val + if orelse_val is not None and _looks_sensitive(orelse_val): + return orelse_val + return body_val if body_val is not None else orelse_val if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): left = _extract_string_from_node(node.left, _depth + 1) right = _extract_string_from_node(node.right, _depth + 1) diff --git a/studio/backend/tests/test_sandbox_hardening.py b/studio/backend/tests/test_sandbox_hardening.py index 1f08740cc6..f7de24aed2 100644 --- a/studio/backend/tests/test_sandbox_hardening.py +++ b/studio/backend/tests/test_sandbox_hardening.py @@ -1897,3 +1897,84 @@ class TestR4_PathlibNameBinding: ) def test_pathlib_name_binding_legit_allowed(self, code): assert not _is_blocked(code), f"legit pathlib name binding blocked: {code!r}" + + +class TestR5_BashGlobUnderSensitiveRoot: + """``cat /etc/sha*ow`` / ``cat /etc/sh?dow`` -- bash expands ``*`` + and ``?`` glob wildcards against the filesystem at runtime. + Statically we cannot enumerate the matches, but a glob immediately + attached to a sensitive root is an attempt to escape literal-path + detection. ``find /etc/ -name '*.conf'`` (whitespace between the + root and the glob) stays allowed because the glob lives in a + separate argument.""" + + @pytest.mark.parametrize( + "cmd", + [ + "cat /etc/sha*ow", + "cat /etc/sh?dow", + "cat /etc/*", + "cat /etc/passw?", + "cat /etc/shado?", + "cat ~/.ssh/*_rsa", + "cat ~/.ssh/id_*", + "cat /home/u/.aws/credential?", + "cat /proc/self/envir*", + "cat /proc/thread-self/env*", + ], + ) + def test_glob_under_sensitive_root_blocked(self, cmd): + assert _find_sensitive_paths(cmd), ( + f"glob under sensitive root leaked: {cmd!r}" + ) + + @pytest.mark.parametrize( + "cmd", + [ + # Glob in a separate argument is fine -- the static gate + # cannot prove it expands to a sensitive file. + "find /etc/ -name '*.conf'", + "find /etc/ -type f", + # No glob, no match + "ls /etc/", + "cat /etc/hosts", + # Project-local globs + "cat ./src/*.py", + "ls ./logs/*.log", + ], + ) + def test_glob_legit_allowed(self, cmd): + assert not _find_sensitive_paths(cmd), ( + f"legit glob blocked: {cmd!r}" + ) + + +class TestR5_TernaryBranchResolution: + """``open('/etc/shadow' if cond else 'data.txt')`` -- either branch + can execute at runtime. The static gate prefers the sensitive + branch so the downstream gate fires.""" + + @pytest.mark.parametrize( + "code", + [ + "open('/etc/shadow' if True else 'data.txt')", + "open('data.txt' if False else '/etc/shadow')", + "open('/etc/shadow' if cond else '/etc/passwd')", + "x = '/etc/shadow'\nopen(x if True else 'data.txt')", + "x = '/etc/shadow'\nopen('data.txt' if False else x)", + # Ternary inside an f-string + "p = '/etc/shadow' if True else 'data.txt'\nopen(p)", + ], + ) + def test_ternary_branch_blocked(self, code): + assert _is_blocked(code), f"ternary branch leaked: {code!r}" + + @pytest.mark.parametrize( + "code", + [ + "open('a.txt' if True else 'b.txt')", + "open('./data.csv' if cond else './data.tsv')", + ], + ) + def test_ternary_legit_allowed(self, code): + assert not _is_blocked(code), f"legit ternary blocked: {code!r}"