From 2ae885ce72403b547f7c6f5dabb83f174b2444d6 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 19 May 2026 11:07:16 +0000 Subject: [PATCH] studio: address sandbox hardening review findings Round-2 fixes for ten issues surfaced by a 20-reviewer code review of the initial hardening patches. Every change is detection-widening or a false-positive narrowing; legitimate tool calls keep working. Direct Python open now flows through _find_sensitive_paths so open('/home/u/.aws/credentials').read() is gated the same as os.system('cat ~/.aws/credentials'). The previous wiring covered only the bash and shell-exec sides. Both SignalEscapeVisitor and NetworkAndIoVisitor fail-closed once the eval / exec literal recursion cap is reached. Wrapping a payload in four or more nested literal exec layers no longer silently bypasses inspection. _find_sensitive_paths scans three projections of the command (raw, backslash-normalised, shlex-dequoted) and recurses into nested bash -c and cmd /c shells. Quote-spliced and Windows-backslash forms of credential paths are all caught. _HOME_PREFIX_RE adds Windows-style home prefixes (USERPROFILE, HOMEDRIVE HOMEPATH, env:USERPROFILE, drive-letter Users) so cross-OS hardening actually applies on Windows. Both sensitive-path regexes now have a path-token start anchor so project-local lookalike paths under workspace, fixtures, and tmp are not blocked. Network host validation for sock.connect and the requests / urllib FQ-prefix branch now use _extract_string_from_node instead of raw ast.Constant checks, so concatenated and f-string literal hosts resolve the same way the open gate already did. pathlib.Path('/etc/shadow').open() is now inspected; the path is extracted from the receiver constructor when node.args is empty. The static-string resolver depth cap moves from 6 to 64, removing the single-character literal-concat bypass while leaving the recursion well inside CPython's default frame limit. The SSH private-key regex gains a filename-end boundary so reading a public ".pub" key stays allowed (legit developer action) while the matching private key is still denied. New regression tests cover one class per finding (TestFinding1 through TestFinding10) plus updated nested-depth coverage; the previous test_nested_depth_does_not_crash assertion was inverted by the fail-closed change and has been replaced. Local sweep: 336 passed (131 upstream + 205 hardening). --- studio/backend/core/inference/tools.py | 292 +++++++++++++---- .../backend/tests/test_sandbox_hardening.py | 295 +++++++++++++++++- 2 files changed, 519 insertions(+), 68 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 299c3234b5..1847d43f6d 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -156,10 +156,18 @@ _FIND_EXEC_FLAGS = frozenset({"-exec", "-execdir", "-ok", "-okdir"}) # ``~/.bashrc``, ``~/.ssh/config``, ``~/.ssh/known_hosts``, ``/etc/hosts``, # ``~/.npm/`` cache, project-local rc files, ``~/.bash_history``, # ``~/.cache/``) MUST stay out of this list — those still flow through. +# SSH private-key alternatives require a filename-end boundary so that +# the matching public key ``~/.ssh/id_rsa.pub`` (legitimate developer +# action) is NOT blocked. Non-key entries deliberately omit the end +# anchor: ``.aws/credentials.bak`` etc. are still credentials. +_SSH_KEY_END = r"(?=$|[\s'\";&|)<])" _HOME_RELATIVE_SENSITIVE = ( - # SSH private keys (config / known_hosts intentionally allowed) - r"\.ssh/id_rsa", r"\.ssh/id_ed25519", r"\.ssh/id_ecdsa", - r"\.ssh/id_dsa", r"\.ssh/identity", + # SSH private keys (config / known_hosts / *.pub intentionally allowed) + rf"\.ssh/id_rsa{_SSH_KEY_END}", + rf"\.ssh/id_ed25519{_SSH_KEY_END}", + rf"\.ssh/id_ecdsa{_SSH_KEY_END}", + rf"\.ssh/id_dsa{_SSH_KEY_END}", + rf"\.ssh/identity{_SSH_KEY_END}", # Cloud provider credentials r"\.aws/credentials", r"\.docker/config\.json", @@ -187,24 +195,43 @@ _ABSOLUTE_SENSITIVE = ( ) # Home-equivalent prefix the path must be preceded by for HOME_RELATIVE -# entries to fire. Covers tilde, $HOME / ${HOME}, /home/, /root, -# /Users/. Each prefix consumes its own trailing slash. +# entries to fire. Covers POSIX tilde / $HOME / ${HOME}, POSIX absolute +# homes (/home/, /root, /Users/), and Windows env-var / drive-letter +# homes (%USERPROFILE%, %HOMEDRIVE%%HOMEPATH%, $env:USERPROFILE, +# C:/Users/). Backslashes get normalized to forward slashes in +# _find_sensitive_paths before matching, so Windows-style C:\Users\... +# input is covered by the C:/Users/... branch here. _HOME_PREFIX_RE = ( r"(?:" r"~" r"|\$\{?HOME\}?" + r"|%USERPROFILE%" + r"|%HOMEDRIVE%%HOMEPATH%" + r"|\$env:USERPROFILE" + r"|\$\{?env:USERPROFILE\}?" r"|/home/[^/\s'\"]+" r"|/root" r"|/Users/[^/\s'\"]+" + r"|[A-Za-z]:/Users/[^/\s'\"]+" r")/" ) +# Path-token start anchor: refuse to match inside a longer path like +# ``./workspace/home/u/.aws/credentials`` or ``/tmp/home/u/.npmrc`` -- +# those are project-local lookalikes, not host credentials. The negative +# lookbehind keeps matches anchored to a real shell token boundary. +_PATH_TOKEN_START = r"(? set[str]: * Home-relative paths (``.ssh/id_rsa``, ``.aws/credentials``, ``.npmrc``, …) match only when prefixed by a home-equivalent token (``~/``, ``$HOME/``, ``/home//``, ``/root/``, - ``/Users//``). This keeps project-local files like - ``./project/.npmrc`` readable. + ``/Users//``, ``%USERPROFILE%/``, ``C:/Users//``). + This keeps project-local files like ``./project/.npmrc`` + readable. * Absolute system paths (``/etc/shadow``, ``/proc//environ``, …) match anywhere they appear. + To resist shell-quote splicing (``cat /etc/sha''dow``, + ``cat ~/'.ssh/id_rsa'``) we scan three projections of the command: + the raw text, a backslash-normalized copy (so Windows + ``C:\\Users\\alice\\.ssh\\id_rsa`` is checked under the + ``C:/Users/…`` branch), and a shlex-dequoted token reconstruction. + Nested ``bash -c '…'`` / ``cmd /c '…'`` payloads are then recursed + into so the bypass surface mirrors ``_find_blocked_commands``. + Used by both ``_bash_exec`` (gates the raw command) and the Python AST gate (via ``_check_args_for_blocked``, so ``os.system('cat ~/.ssh/id_rsa')`` is caught the same way as the @@ -228,17 +264,70 @@ def _find_sensitive_paths(command: str) -> set[str]: The allow-list intentionally excludes common LLM-developer-tool paths (``~/.gitconfig``, ``~/.bashrc``, ``~/.ssh/config``, - ``~/.ssh/known_hosts``, ``/etc/hosts``, ``~/.cache/``, project-local - rc files) so legitimate tool calls like ``cat ~/.gitconfig`` or - ``find src/ -name '*.py'`` are not blocked. + ``~/.ssh/known_hosts``, ``/etc/hosts``, ``~/.cache/``, ``*.pub`` + SSH public keys, project-local rc files) so legitimate tool calls + like ``cat ~/.gitconfig`` or ``find src/ -name '*.py'`` still work. """ if not command: return set() + + # Tokenize once: powers both the dequoted scan target and the + # nested-shell recursion below. shlex matches the platform default. + try: + if sys.platform == "win32": + tokens = shlex.split(command, posix = False) + else: + lexer = shlex.shlex(command, posix = True, punctuation_chars = ";&|()`") + lexer.whitespace_split = True + tokens = list(lexer) + except ValueError: + tokens = command.split() + + scan_targets = [command] + # Windows path normalisation: regex uses forward slashes so + # ``C:\Users\alice\.ssh\id_rsa`` and ``%USERPROFILE%\.aws\credentials`` + # have to be presented in normalized form to match. + if "\\" in command: + scan_targets.append(command.replace("\\", "/")) + # Shlex-dequoted reconstruction: collapses ``cat /etc/sha''dow`` into + # ``cat /etc/shadow`` so quote splicing cannot hide a sensitive token. + if tokens: + scan_targets.append(" ".join(tokens)) + found: set[str] = set() - for m in _HOME_SENSITIVE_RE.finditer(command): - found.add(m.group(0)) - for m in _ABSOLUTE_SENSITIVE_RE.finditer(command): - found.add(m.group(0)) + for text in scan_targets: + for m in _HOME_SENSITIVE_RE.finditer(text): + found.add(m.group(0)) + for m in _ABSOLUTE_SENSITIVE_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 + # ``cmd /c type %USERPROFILE%\.aws\credentials`` both surface. + _SHELLS = {"bash", "sh", "zsh", "dash", "ksh", "csh", "tcsh", "fish"} + _SHELLS_WIN = {"cmd", "cmd.exe"} + for i, token in enumerate(tokens): + tok_lower = token.lower() + is_unix_c = tok_lower == "-c" or ( + tok_lower.startswith("-") + and tok_lower.endswith("c") + and not tok_lower.startswith("--") + ) + is_win_c = tok_lower == "/c" + if not (is_unix_c or is_win_c) or i < 1 or i + 1 >= len(tokens): + continue + for j in range(i - 1, -1, -1): + prev = tokens[j] + if prev.startswith("-"): + continue + if is_win_c and prev.startswith("/") and len(prev) <= 3: + continue + prev_base = os.path.basename(prev).lower() + if is_unix_c and prev_base in _SHELLS: + found |= _find_sensitive_paths(tokens[i + 1]) + elif is_win_c and prev_base in _SHELLS_WIN: + found |= _find_sensitive_paths(tokens[i + 1]) + break return found @@ -977,10 +1066,13 @@ def _check_signal_escape_patterns(code: str): are themselves resolvable. Closes ``open(f'/etc/{"shadow"}')``. Resolution is depth-capped so adversarial deeply-nested - ``'a' + ('b' + ('c' + ...))`` cannot blow the stack. + ``'a' + ('b' + ('c' + ...))`` cannot blow the stack. The cap + (64) sits well below CPython's default recursion limit and + comfortably above any realistic credential-path concatenation + (the longest sensitive path is roughly 30 chars). Returns ``None`` whenever any subpart fails to resolve. """ - if _depth > 6: + if _depth > 64: return None if isinstance(node, ast.Constant) and isinstance(node.value, str): return node.value @@ -1116,18 +1208,8 @@ def _check_signal_escape_patterns(code: str): if isinstance(func, ast.Name) and func.id in ("eval", "exec"): if node.args: payload = _extract_string_from_node(node.args[0]) - if payload is not None and self._eval_depth < 3: - try: - inner_tree = ast.parse(payload, mode = "exec") - except SyntaxError: - inner_tree = None - if inner_tree is not None: - self._eval_depth += 1 - try: - self.visit(inner_tree) - finally: - self._eval_depth -= 1 - elif payload is None: + if payload is None: + # Dynamic payload: classic injection vector. shell_escapes.append( { "type": "shell_escape_dynamic", @@ -1138,6 +1220,31 @@ def _check_signal_escape_patterns(code: str): ), } ) + elif self._eval_depth >= 3: + # Fail-closed at the recursion cap so an attacker + # cannot bypass inspection by wrapping the payload + # in four-plus nested literal eval/exec layers. + shell_escapes.append( + { + "type": "shell_escape_dynamic", + "line": node.lineno, + "description": ( + f"{func.id}() literal payload nesting " + "exceeds sandbox inspection depth" + ), + } + ) + else: + try: + inner_tree = ast.parse(payload, mode = "exec") + except SyntaxError: + inner_tree = None + if inner_tree is not None: + self._eval_depth += 1 + try: + self.visit(inner_tree) + finally: + self._eval_depth -= 1 func_name = None if isinstance(func, ast.Attribute): @@ -1818,17 +1925,32 @@ def _check_signal_escape_patterns(code: str): if isinstance(func, ast.Name) and func.id in ("eval", "exec"): if node.args: payload = _extract_string_from_node(node.args[0]) - if payload is not None and self._eval_depth < 3: - try: - inner_tree = ast.parse(payload, mode = "exec") - except SyntaxError: - inner_tree = None - if inner_tree is not None: - self._eval_depth += 1 + if payload is not None: + if self._eval_depth >= 3: + # Fail-closed at the depth cap so nested literal + # ``exec(exec(exec(exec("open('/etc/shadow')"))))`` + # cannot tunnel past inspection. + sensitive_file_reads.append( + { + "type": "sensitive_file_read", + "line": getattr(node, "lineno", -1), + "description": ( + f"{func.id}() literal payload nesting " + "exceeds sandbox inspection depth" + ), + } + ) + else: try: - self.visit(inner_tree) - finally: - self._eval_depth -= 1 + inner_tree = ast.parse(payload, mode = "exec") + except SyntaxError: + inner_tree = None + if inner_tree is not None: + self._eval_depth += 1 + try: + self.visit(inner_tree) + finally: + self._eval_depth -= 1 parts: list[str] = [] cur = node.func @@ -1858,13 +1980,13 @@ def _check_signal_escape_patterns(code: str): and node.args ): a0 = node.args[0] - host_lit = None + # Use the static-string resolver so a concatenated / + # f-string host literal (e.g. ``'169.254.' + '169.254'``) + # is recognised the same as a bare ast.Constant. if isinstance(a0, ast.Tuple) and a0.elts: - e0 = a0.elts[0] - if isinstance(e0, ast.Constant) and isinstance(e0.value, str): - host_lit = e0.value - elif isinstance(a0, ast.Constant) and isinstance(a0.value, str): - host_lit = a0.value + host_lit = _extract_string_from_node(a0.elts[0]) + else: + host_lit = _extract_string_from_node(a0) if host_lit: if _is_metadata_host(host_lit): network_calls.append( @@ -1900,16 +2022,17 @@ def _check_signal_escape_patterns(code: str): ) # 2) Extract literal host (URL string or (host, port) tuple). + # Same static-string resolver as elsewhere so ``'http://' + + # '169.254.169.254'`` and ``f'http://{"169.254.169.254"}/'`` + # are resolvable the same as a bare constant. host_arg = None url_arg = None if node.args: a0 = node.args[0] - if isinstance(a0, ast.Constant) and isinstance(a0.value, str): - url_arg = a0.value - elif isinstance(a0, ast.Tuple) and a0.elts: - e0 = a0.elts[0] - if isinstance(e0, ast.Constant) and isinstance(e0.value, str): - host_arg = e0.value + if isinstance(a0, ast.Tuple) and a0.elts: + host_arg = _extract_string_from_node(a0.elts[0]) + else: + url_arg = _extract_string_from_node(a0) if url_arg and host_arg is None: m = re.match(r"^\w+://([^/?#]+)", url_arg) if m: @@ -1936,23 +2059,68 @@ def _check_signal_escape_patterns(code: str): } ) + # ``fq`` resolves only when the attribute chain ends in a Name. + # ``Path('/etc/shadow').open()`` has a Call in the chain, which + # short-circuits fq to ``"open"`` -- so treat any Attribute call + # whose attr is ``open`` as a candidate too, then resolve the + # actual path from the receiver below. is_open_call = ( (isinstance(node.func, ast.Name) and node.func.id == "open") or fq in ("io.open", "pathlib.Path.open") or fq.endswith(".open") + or ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "open" + ) ) - if is_open_call and node.args: - # Use the static-string resolver so BinOp.Add of constants - # and JoinedStr (f-string) of constants are caught — not just - # bare ast.Constant. Closes ``open('/etc/' + 'shadow')`` - # and ``open(f'/etc/{"shadow"}')``. - path_lit = _extract_string_from_node(node.args[0]) + if is_open_call: + # Resolve the open target. The literal path can live in + # ``open(arg)`` or in the receiver constructor for the + # ``Path('/etc/shadow').open()`` form (Fix #8). + path_lit = None + if node.args: + path_lit = _extract_string_from_node(node.args[0]) + if path_lit is None and isinstance(node.func, ast.Attribute) and node.func.attr == "open": + receiver = node.func.value + if isinstance(receiver, ast.Call) and receiver.args: + ctor_parts: list[str] = [] + cur = receiver.func + while isinstance(cur, ast.Attribute): + ctor_parts.insert(0, cur.attr) + cur = cur.value + if isinstance(cur, ast.Name): + ctor_parts.insert(0, cur.id) + ctor_fq = ".".join(ctor_parts) if ctor_parts else "" + if ctor_fq in ("Path", "pathlib.Path") or ctor_fq.endswith(".Path"): + path_lit = _extract_string_from_node(receiver.args[0]) + if path_lit: + # Match both the original literal and a backslash- + # normalized projection so Windows-style paths + # ``C:\Users\alice\.aws\credentials`` reach the + # /Users// home prefix. + candidates = {path_lit} + if "\\" in path_lit: + candidates.add(path_lit.replace("\\", "/")) + flagged = False - if any(path_lit.startswith(p) for p in _SENSITIVE_FILE_PREFIXES): - flagged = True - elif _SENSITIVE_FILE_RE.match(path_lit): - flagged = True + for cand in candidates: + if any(cand.startswith(p) for p in _SENSITIVE_FILE_PREFIXES): + flagged = True + break + if _SENSITIVE_FILE_RE.match(cand): + flagged = True + break + # The credential / process-state allow-list lives + # in ``_find_sensitive_paths`` (Patch B). Reuse it + # so ``open('/home/u/.aws/credentials')`` is + # blocked the same as the bash equivalent -- + # otherwise the new home/credential guard would + # only cover shell command flows and leave the + # direct Python open() path open. + if _find_sensitive_paths(cand): + flagged = True + break if flagged: sensitive_file_reads.append( { diff --git a/studio/backend/tests/test_sandbox_hardening.py b/studio/backend/tests/test_sandbox_hardening.py index eef45736d3..8035717f34 100644 --- a/studio/backend/tests/test_sandbox_hardening.py +++ b/studio/backend/tests/test_sandbox_hardening.py @@ -322,16 +322,299 @@ class TestPatchD_EvalExecDynamicPayload: class TestPatchD_NestedDepthCap: + """Fail-closed once recursion exceeds the inspection cap. The previous + silently-drop behaviour let four-or-more nested literal exec layers + smuggle ``sudo whoami`` or ``open('/etc/shadow')`` past the gate.""" + def test_nested_depth_does_not_crash(self): - # 10 levels of nested exec(exec(...)) — depth cap at 3 means the - # recursion stops early. Must not crash, must not false-positive on - # the innocuous innermost payload. + # 10 levels of exec nesting; must not blow the stack regardless of + # whether the verdict is "blocked" or "allowed". payload = "print(1)" for _ in range(10): payload = f"exec({payload!r})" - # Outer payload is now exec("exec(\"exec(...))\")") with no - # blocked/sensitive content. Must remain ALLOWED. - assert not _is_blocked(payload), payload[:80] + "..." + # Just exercises the code path; the assertion is "did not raise". + _is_blocked(payload) + + @pytest.mark.parametrize( + "inner", + [ + f"import os; os.system('{SUDO} whoami')", + "open('/etc/shadow').read()", + "import requests; requests.get('http://169.254.169.254/')", + ], + ) + @pytest.mark.parametrize("depth", [4, 5, 6]) + def test_deep_nested_payload_fails_closed(self, inner, depth): + payload = inner + for _ in range(depth): + payload = f"exec({payload!r})" + assert _is_blocked(payload), ( + f"depth={depth} bypass: {payload[:80]}..." + ) + + @pytest.mark.parametrize("inner", ["print(1)", "x = 1 + 2"]) + @pytest.mark.parametrize("depth", [1, 2, 3]) + def test_shallow_innocuous_payload_still_allowed(self, inner, depth): + payload = inner + for _ in range(depth): + payload = f"exec({payload!r})" + assert not _is_blocked(payload), ( + f"shallow innocuous depth={depth} now blocked: {payload!r}" + ) + + +# --------------------------------------------------------------------------- +# Review-round 2 regressions: fixes for findings surfaced by reviewer.py. +# Every test here corresponds to a specific finding number from the +# 20-reviewer aggregated review. +# --------------------------------------------------------------------------- + + +class TestFinding1_DirectOpenSensitivePaths: + """Finding #1 [15/20]: ``open()`` was missing the new home / + credential / process-state path guard. ``cat ~/.ssh/id_rsa`` was + blocked but ``open('~/.ssh/id_rsa').read()`` was not.""" + + @pytest.mark.parametrize( + "code", + [ + "open('/home/u/.aws/credentials').read()", + "open('/Users/alice/.aws/credentials').read()", + "open('/root/.docker/config.json').read()", + "open('/home/u/.ssh/id_rsa').read()", + "open('/proc/self/environ').read()", + "open('/proc/self/maps').read()", + "open('/proc/self/auxv', 'rb').read()", + # Wrapped in literal exec — Patch D recursion plus the new + # open() wiring must combine. + "exec(\"open('/home/u/.aws/credentials').read()\")", + "exec(\"open('/proc/self/environ').read()\")", + ], + ) + def test_direct_open_credential_blocked(self, code): + assert _is_blocked(code), f"expected to block: {code!r}" + + @pytest.mark.parametrize( + "code", + [ + # Project-local lookalikes must remain allowed. + "open('./fixtures/etc/shadow.txt')", + "open('/tmp/project/etc/shadow')", + "open('/tmp/home/u/.npmrc')", + "open('./workspace/home/u/.aws/credentials')", + # Real common paths the AI tools touch. + "open('README.md')", + "open('src/main.py')", + "open('logs/today.log', 'w')", + ], + ) + def test_project_local_open_still_allowed(self, code): + assert not _is_blocked(code), ( + f"regression: project-local open() now blocked: {code!r}" + ) + + +class TestFinding4_ShellQuoteSplicing: + """Finding #4 [4/20]: raw-text regex saw past shell quote tricks. + ``cat /etc/sha''dow`` is executed by the shell as ``cat /etc/shadow`` + but the regex returned no match.""" + + @pytest.mark.parametrize( + "cmd", + [ + "cat /etc/sha''dow", + "cat ~/'.ssh/id_rsa'", + "cat $HOME/.ssh/id_''rsa", + "cat /proc/self/env''iron", + "cat /'etc'/shadow", + "bash -c \"cat ~/'.ssh/id_rsa'\"", + "bash -c 'cat /etc/sha\"\"dow'", + ], + ) + def test_quote_spliced_sensitive_paths_blocked(self, cmd): + assert _find_sensitive_paths(cmd), f"missed splice: {cmd!r}" + + @pytest.mark.parametrize( + "cmd", + [ + # Quote-spliced project-local lookalikes must still pass. + "cat ./fixtures/etc/sha''dow.txt", + "cat ./logs/'today.log'", + ], + ) + def test_quote_spliced_project_local_allowed(self, cmd): + assert not _find_sensitive_paths(cmd), ( + f"regression: spliced project-local blocked: {cmd!r}" + ) + + +class TestFinding5_WindowsHomePrefixes: + """Finding #5 [3/20]: ``_HOME_PREFIX_RE`` only knew POSIX homes. + Windows ``%USERPROFILE%\\.aws\\credentials`` was not detected.""" + + @pytest.mark.parametrize( + "cmd", + [ + r"type %USERPROFILE%\.aws\credentials", + r"type %USERPROFILE%\.ssh\id_rsa", + r"type %HOMEDRIVE%%HOMEPATH%\.docker\config.json", + r"type C:\Users\alice\.aws\credentials", + r"type C:\Users\alice\.ssh\id_ed25519", + r"type $env:USERPROFILE\.aws\credentials", + ], + ) + def test_windows_home_paths_blocked(self, cmd): + assert _find_sensitive_paths(cmd), f"missed Windows path: {cmd!r}" + + @pytest.mark.parametrize( + "cmd", + [ + r"type C:\Users\alice\projects\app\config.json", + r"type %USERPROFILE%\Documents\readme.txt", + r"dir C:\Users\alice\Downloads", + ], + ) + def test_legitimate_windows_paths_allowed(self, cmd): + assert not _find_sensitive_paths(cmd), ( + f"regression: legit Windows path blocked: {cmd!r}" + ) + + +class TestFinding6_DeepLiteralConcat: + """Finding #6 [2/20]: the static-string resolver bailed past depth 6, + so ``open('/'+'e'+'t'+'c'+'/'+'s'+'h'+'a'+'d'+'o'+'w')`` was + silently allowed.""" + + @pytest.mark.parametrize( + "code", + [ + "open('/'+'e'+'t'+'c'+'/'+'s'+'h'+'a'+'d'+'o'+'w').read()", + "open('/'+'e'+'t'+'c'+'/'+'p'+'a'+'s'+'s'+'w'+'d').read()", + "open('/'+'p'+'r'+'o'+'c'+'/'+'s'+'e'+'l'+'f'+'/'+'e'+'n'+'v'+'i'+'r'+'o'+'n').read()", + ], + ) + def test_deep_literal_concat_blocked(self, code): + assert _is_blocked(code), f"depth bypass: {code!r}" + + +class TestFinding7_NetworkHostStaticResolver: + """Finding #7 [1/20]: network host validation only handled + ``ast.Constant``; concat / f-string hosts bypassed.""" + + @pytest.mark.parametrize( + "code", + [ + "import requests; requests.get('http://' + '169.254.169.254/')", + "import requests; requests.get(f'http://{\"169.254.169.254\"}/')", + "import socket; s=socket.socket(); s.connect(('169.254.' + '169.254', 80))", + "exec(\"import requests; requests.get('http://' + '169.254.169.254/')\")", + ], + ) + def test_dynamic_metadata_host_blocked(self, code): + assert _is_blocked(code), f"metadata bypass: {code!r}" + + @pytest.mark.parametrize( + "code", + [ + "import requests; requests.get('https://' + 'wikipedia.org/')", + "import requests; requests.get(f'https://{\"huggingface.co\"}/x')", + ], + ) + def test_dynamic_trusted_host_allowed(self, code): + assert not _is_blocked(code), ( + f"regression: trusted host with dynamic literal blocked: {code!r}" + ) + + +class TestFinding8_PathlibPathOpen: + """Finding #8 [1/20]: when the open target lives in the receiver + constructor (``Path('/etc/shadow').open()``) rather than in + ``open(arg)``, the gate skipped inspection.""" + + @pytest.mark.parametrize( + "code", + [ + "from pathlib import Path\nPath('/etc/shadow').open().read()", + "from pathlib import Path\nPath('/etc/' + 'shadow').open().read()", + "import pathlib\npathlib.Path('/etc/passwd').open().read()", + "from pathlib import Path\nPath('/home/u/.aws/credentials').open().read()", + ], + ) + def test_pathlib_path_open_blocked(self, code): + assert _is_blocked(code), f"pathlib bypass: {code!r}" + + @pytest.mark.parametrize( + "code", + [ + "from pathlib import Path\nPath('data.csv').open()", + "from pathlib import Path\nPath('logs/today.log').open('w')", + "from pathlib import Path\nPath('README.md').open()", + ], + ) + def test_pathlib_legit_path_allowed(self, code): + assert not _is_blocked(code), ( + f"regression: legit Path.open() blocked: {code!r}" + ) + + +class TestFinding9_ProjectLocalFalsePositives: + """Finding #9 [3/20]: regex without a path-token start anchor + blocked project-local lookalikes like ``./workspace/home/u/.aws/...`` + which are project paths, not host credentials.""" + + @pytest.mark.parametrize( + "cmd", + [ + "cat ./workspace/home/u/.aws/credentials", + "cat /tmp/home/u/.npmrc", + "cat ./fixtures/etc/shadow.txt", + "cat /tmp/project/etc/shadow", + "cat project/Users/alice/.aws/credentials", + "ls /opt/Users/svc/.kube/config", + "find /tmp/root/.gnupg -type f", + ], + ) + def test_project_local_lookalikes_allowed(self, cmd): + assert not _find_sensitive_paths(cmd), ( + f"false-positive (tool calling dumber): {cmd!r}" + ) + + +class TestFinding10_PublicSshKeyAllowed: + """Finding #10 [1/20]: SSH private-key alternatives matched without + a filename boundary, so ``cat ~/.ssh/id_rsa.pub`` was blocked even + though reading a public key is a legitimate developer action.""" + + @pytest.mark.parametrize( + "cmd", + [ + "cat ~/.ssh/id_rsa.pub", + "cat ~/.ssh/id_ed25519.pub", + "cat ~/.ssh/id_ecdsa.pub", + "cat /home/u/.ssh/id_rsa.pub", + "cat /Users/alice/.ssh/id_rsa.pub", + "ssh-keygen -lf ~/.ssh/id_rsa.pub", + ], + ) + def test_public_ssh_keys_allowed(self, cmd): + assert not _find_sensitive_paths(cmd), ( + f"regression: public key read blocked: {cmd!r}" + ) + + @pytest.mark.parametrize( + "cmd", + [ + # Negative cross-check — the .pub end anchor must not relax + # the actual private-key block. + "cat ~/.ssh/id_rsa", + "cat ~/.ssh/id_ed25519", + "cat /home/u/.ssh/id_ecdsa", + ], + ) + def test_private_ssh_keys_still_blocked(self, cmd): + assert _find_sensitive_paths(cmd), ( + f"regression: private key now allowed: {cmd!r}" + ) # ---------------------------------------------------------------------------