studio/sandbox: close bash glob and ternary IfExp bypasses

Two final bypass classes from the round-5 follow-up list are now
closed (552 tests passing):

1. Bash glob under a sensitive root. ``cat /etc/sha*ow``, ``cat
   /etc/sh?dow``, ``cat /etc/*``, ``cat ~/.ssh/id_*`` -- the shell
   expands ``*`` / ``?`` against the filesystem at runtime, so the
   literal-path scan never sees ``/etc/shadow``. A new
   ``_SENSITIVE_ROOT_WITH_GLOB_RE`` mirrors the existing
   ``_SENSITIVE_ROOT_WITH_EXPANSION_RE`` (which gates ``$(...)`` /
   backtick substitutions) for the ``*`` / ``?`` family. The
   ``[^\s'\";&|`$]*`` literal-text-only constraint keeps the match
   attached to the sensitive root token, so ``find /etc/ -name
   '*.conf'`` (whitespace between root and glob) and project-local
   globs like ``./src/*.py`` stay allowed.

2. Ternary ``IfExp`` branches. ``open('/etc/shadow' if cond else
   'data.txt')`` previously slipped because
   ``_extract_string_from_node`` had no ``ast.IfExp`` handler.
   Either branch can execute at runtime; the gate now resolves both
   branches and prefers the sensitive one (via the same
   ``_looks_sensitive`` check that backs the binding-bias) so the
   downstream check fires. Falls back to whichever branch resolved
   when neither is sensitive.

24 new regression tests cover the glob class (10 blocked, 6 allowed)
and ternary (6 blocked, 2 allowed).
This commit is contained in:
danielhanchen 2026-05-24 14:43:23 +00:00
commit e400dac77d
2 changed files with 124 additions and 0 deletions

View file

@ -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)

View file

@ -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}"