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).
This commit is contained in:
Daniel Han 2026-05-19 11:07:16 +00:00
commit 2ae885ce72
2 changed files with 519 additions and 68 deletions

View file

@ -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/<user>, /root,
# /Users/<user>. Each prefix consumes its own trailing slash.
# entries to fire. Covers POSIX tilde / $HOME / ${HOME}, POSIX absolute
# homes (/home/<u>, /root, /Users/<u>), and Windows env-var / drive-letter
# homes (%USERPROFILE%, %HOMEDRIVE%%HOMEPATH%, $env:USERPROFILE,
# C:/Users/<u>). 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"(?<![A-Za-z0-9_./~$%-])"
_HOME_SENSITIVE_RE = re.compile(
_HOME_PREFIX_RE + r"(?:" + "|".join(_HOME_RELATIVE_SENSITIVE) + r")",
_PATH_TOKEN_START
+ _HOME_PREFIX_RE
+ r"(?:"
+ "|".join(_HOME_RELATIVE_SENSITIVE)
+ r")",
re.IGNORECASE,
)
_ABSOLUTE_SENSITIVE_RE = re.compile(
r"(?:" + "|".join(_ABSOLUTE_SENSITIVE) + r")",
_PATH_TOKEN_START + r"(?:" + "|".join(_ABSOLUTE_SENSITIVE) + r")",
re.IGNORECASE,
)
@ -216,11 +243,20 @@ def _find_sensitive_paths(command: str) -> set[str]:
* Home-relative paths (``.ssh/id_rsa``, ``.aws/credentials``,
``.npmrc``, ) match only when prefixed by a home-equivalent
token (``~/``, ``$HOME/``, ``/home/<user>/``, ``/root/``,
``/Users/<user>/``). This keeps project-local files like
``./project/.npmrc`` readable.
``/Users/<user>/``, ``%USERPROFILE%/``, ``C:/Users/<user>/``).
This keeps project-local files like ``./project/.npmrc``
readable.
* Absolute system paths (``/etc/shadow``, ``/proc/<pid>/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/<u>/ 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(
{

View file

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