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:
parent
6efb0f64ea
commit
2ae885ce72
2 changed files with 519 additions and 68 deletions
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue