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:
parent
115810eae3
commit
e400dac77d
2 changed files with 124 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue