studio: tighten sandbox blocklist precision (bash, hf upload, NOFILE) (#5487)
* studio: tighten sandbox blocklist precision (bash, hf upload, NOFILE) Three precision fixes in core/inference/tools.py. Same security boundary; fewer false positives that broke legitimate sandbox use. bash blocklist: The per-token loop introduced in #5375 fired on any blocklist word in any token position, so the entirely benign `grep -r curl .`, `echo source the data`, and `ls /usr/bin/curl` were rejected with "blocked command 'curl'". The position-anchored regex already covers real command-position invocations, including `;rm`, `&&wget`, `$(rm)`, `<(rm)`, backticked subshells, and `/usr/bin/sudo`. The token loop is re-scoped: it only fires when the previous shlex token is a shell separator (or at start of line), so split-quoting obfuscations like `r''m -rf /` are still caught (shlex collapses them to a single command-position token) while argument-position blocklist words pass through. Trailing meta-chars glued to a shlex token (`rm;`) are stripped before basename matching. hf upload AST gate: `_method_call_is_hf_upload` previously matched any method named `upload_file` / `upload_folder` / `upload_large_folder` / `create_commit` on any receiver, so paramiko.SFTPClient.upload_file, boto3.create_commit, and similar non-HF SDK methods were rejected. The fallback now requires an `import huggingface_hub` / `import hf_api` / `from huggingface_hub import ...` somewhere in the same module. Fully-qualified huggingface_hub.upload_file(...) calls are unchanged. NOFILE env knob: `RLIMIT_NOFILE = (1024, 1024)` was the only sandbox rlimit without an env override. 1024 is below Linux's typical soft default and below what multi-shard safetensors mmap chains need on Llama-3 70B-class loads. Default is now 16384 with UNSLOTH_STUDIO_SANDBOX_NOFILE, parity with the other rlimits. 15 new bash-blocklist-position tests pin both the false-positive fixes and the still-blocked invariants (semicolon, &&, subshell, backtick, split-quote, /usr/bin/ prefix, nested bash -c). 4 new hf-upload-import-gate tests pin both the false-positive allowances and that HF-imported uses are still blocked. 1 new pin asserts the NOFILE env var is wired. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: cover command wrappers, find -exec, dynamic HF imports, NOFILE clamp Reviewer follow-ups to the sandbox blocklist precision change. Command-position scanner missed Bash command-prefix wrappers and inline shell assignments. shlex tokenised `env curl`, `time curl`, `nohup rm`, `FOO=bar curl`, `sudo rm`, etc. with the prefix at command position and the real command at argument position, so the position-anchored check returned set() while pre-PR's per-token scan caught them. Likewise the position-anchored regex requires `^` or a shell separator before the command, so `env curl` slipped through. Reworked the scanner to track an expect_command flag plus a prefix_pending flag: - assignments (FOO=bar) keep expect_command=True for the next token, - flags ('-oL', '--') keep it intact while prefix_pending is set, - numeric duration args ('timeout 1 cmd') skip without breaking expect_command, - known wrappers (env, command, builtin, exec, time, nohup, nice, setsid, stdbuf, timeout, ionice, chroot, sudo, doas, su, xargs) set prefix_pending so the wrapper's command is still checked, - shell separators now include `{`, `}`, `)`, `then`, `do`, `else`, `elif` so brace groups and if/then/while/do bodies are recognised as command positions. Also lex with `shlex.shlex(punctuation_chars=";&|()`")` so split-quote forms like `echo done; r''m -rf /tmp/x` and `echo done;r''m` tokenise as `[..., ';', 'rm', ...]` and the command position check fires. Added a small `find -exec CMD ... ;` / `-execdir CMD ... ;` pass so `find . -exec rm -f {} +` and friends are caught even though the direct token is at argument position to `find`. Dynamic Hugging Face imports were treated as no-HF-in-scope. The upload-method gate now also resolves `__import__('huggingface_hub')`, `importlib.import_module('huggingface_hub')`, and bare `import_module('huggingface_hub')` (via `from importlib import import_module`) as HF imports, so HfApi().upload_file via dynamic import is still blocked. RLIMIT_NOFILE: setrlimit(NOFILE, (16384, 16384)) silently failed if the parent's hard cap is below the requested value; the broad except swallowed the OSError and left the sandbox at the parent's default. Clamp the requested value to the inherited hard limit before calling setrlimit. Test cleanup: the existing test_cat_with_word_source_allowed had `assert ... or True` so it could not fail; rewrote it to assert the actual return value plus the two membership checks. Added parametrised coverage for shell prefix wrappers, find -exec / xargs, brace groups, if/then, while/do, split-quote command-name forms, and dynamic HF import upload patterns. Test: - python -m pytest studio/backend/tests/test_sandbox_tools.py -q -> 90 passed (was 67 before this commit) - full studio/backend/tests/ minus llama_cpp_load_progress_live and GPU CUDA_VISIBLE_DEVICES tests (pre-existing isolation flake) -> 1063 passed * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: catch bare-name HF upload calls in AST gate `from huggingface_hub import upload_file; upload_file(...)` is a canonical HF call shape that the previous Attribute-only check missed: the bare-name call lands as ast.Name (not ast.Attribute), so the fuzzy gate skipped it. Extend _method_call_is_hf_upload to also match ast.Name when HF is in scope. Same import-gating discipline as the Attribute branch, so paramiko/boto3 and locally-defined `def upload_file(...)` helpers without HF imports still pass. Pins: 4 new TestHfUploadImportGate cases (upload_file/folder/create_commit bare-name imports blocked; local upload_file without HF import allowed). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: scope HF uploads to sandbox-local literals; block env / token leaks The previous gate dropped every HF upload call. Two refinements make it precise enough to allow legitimate sandbox->HF uploads while still catching credential / file exfil: - path_or_fileobj / folder_path / create_commit operation paths must be sandbox-local relative-path literals (no '/', '~', drive letter, or '..' segments). Variable / dynamic paths are rejected. - Any positional or keyword argument that statically resolves to os.environ / os.environ.get / os.getenv / bare getenv / subprocess shape readers is rejected (env-var exfil). - token / hf_token / api_token / api_key / auth_token / access_token / password / secret kwargs are always rejected; sandbox env strips all parent credentials by construction, so any value here is hard-coded or lifted. Recursive subtree walk in _reads_env_or_secret catches wrapper shapes (str(os.environ), json.dumps(os.environ.items()), etc.). Add TestSandboxEnvIsolation: pin that _build_safe_env builds the env from a whitelist, not by stripping. Cover Linux/macOS/WSL/Windows secret shapes. The whitelist is PATH / HOME / TMPDIR / LANG / TERM / PYTHONIOENCODING (+ VIRTUAL_ENV / SystemRoot when applicable); HOME points at the sandbox workdir, so HF / wandb / aws SDKs cannot reach the operator's ~/.cache credentials. Test classes added: - TestHfUploadSandboxLocalPaths (relative literals allowed; absolute, drive-letter, '~', '..', mid-path traversal, dynamic vars, and open() of unsafe paths blocked, including create_commit recursion). - TestHfUploadEnvAndSecretLeakBlock (os.environ subscript/get/getenv, bare getenv, subprocess.check_output, str(os.environ), token=, hf_token=, api_key=, and create_commit operations referencing env). - TestSandboxEnvIsolation (no parent secret leaks into sandbox env). 131 tests in test_sandbox_tools.py pass. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
4065cb9987
commit
aab371a068
2 changed files with 911 additions and 61 deletions
|
|
@ -109,40 +109,120 @@ _BLOCKED_COMMANDS = (
|
|||
)
|
||||
|
||||
|
||||
_SHELL_SEPARATORS = frozenset(
|
||||
{";", "&&", "||", "|", "&", "\n", "(", ")", "`", "{", "}"}
|
||||
)
|
||||
# Bash keywords that introduce a new command position (then $cmd, do $cmd, etc.).
|
||||
_SHELL_KEYWORDS_AS_SEP = frozenset({"then", "do", "else", "elif"})
|
||||
# Wrappers whose next non-flag argument is itself the command Bash will exec.
|
||||
_COMMAND_PREFIXES = frozenset(
|
||||
{
|
||||
"env",
|
||||
"command",
|
||||
"builtin",
|
||||
"exec",
|
||||
"time",
|
||||
"nohup",
|
||||
"nice",
|
||||
"setsid",
|
||||
"stdbuf",
|
||||
"timeout",
|
||||
"ionice",
|
||||
"chroot",
|
||||
"sudo",
|
||||
"doas",
|
||||
"su",
|
||||
"xargs",
|
||||
}
|
||||
)
|
||||
_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
||||
_FIND_EXEC_FLAGS = frozenset({"-exec", "-execdir", "-ok", "-okdir"})
|
||||
|
||||
|
||||
def _find_blocked_commands(command: str) -> set[str]:
|
||||
"""Detect blocked commands using shlex tokenization and regex scanning.
|
||||
"""Detect blocked commands at shell command position only.
|
||||
|
||||
Catches: full paths (/usr/bin/sudo), quoted strings ("sudo"),
|
||||
split-quotes (su""do), backslash escapes (\\rm), and command-position
|
||||
words after ;, |, &&, $().
|
||||
A token is at command position if it is the first token, or if the
|
||||
preceding token is a shell separator / brace-group opener / keyword
|
||||
that starts a new command (`then`, `do`, etc.), or a command-prefix
|
||||
wrapper like `env` / `time` / `xargs` (the next token is the real
|
||||
command). Tokens in argument position (`grep -r curl .`,
|
||||
`echo source the data`, `ls /usr/bin/curl`) are passed through.
|
||||
Also scans `find ... -exec CMD` and recurses into bash -c / cmd /c.
|
||||
"""
|
||||
blocked = set()
|
||||
blocked: set[str] = set()
|
||||
|
||||
# 1. shlex tokenization (handles quotes, escapes, concatenation)
|
||||
# shlex with punctuation_chars splits `;`, `&&`, `||`, `|`, `(`, `)`, `` ` ``
|
||||
# off as their own tokens so we can detect command position even when a
|
||||
# caller writes `echo done; rm -rf x` (no whitespace) or quote-splits the
|
||||
# command name itself (`r''m` collapses to a single token `rm` at command
|
||||
# position after the `;` separator).
|
||||
try:
|
||||
tokens = (
|
||||
shlex.split(command)
|
||||
if sys.platform != "win32"
|
||||
else shlex.split(command, posix = False)
|
||||
)
|
||||
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()
|
||||
|
||||
for token in tokens:
|
||||
base = os.path.basename(token).lower()
|
||||
# Strip common Windows executable extensions so that
|
||||
# runas.exe, shutdown.bat, etc. match the blocklist.
|
||||
def _token_basename(tok: str) -> str:
|
||||
# shlex may glue trailing meta-chars onto a token (`rm;`); strip them
|
||||
# so the basename match still hits `rm`. Leading shell-state chars
|
||||
# likewise.
|
||||
tok = tok.strip(";&|()`{}")
|
||||
base = os.path.basename(tok).lower()
|
||||
stem, ext = os.path.splitext(base)
|
||||
if ext in {".exe", ".com", ".bat", ".cmd"}:
|
||||
base = stem
|
||||
return base
|
||||
|
||||
expect_command = True # start of string is a command position
|
||||
prefix_pending = False # last command-position token was env/time/timeout/xargs/...
|
||||
for token in tokens:
|
||||
if token in _SHELL_SEPARATORS or token in _SHELL_KEYWORDS_AS_SEP:
|
||||
expect_command = True
|
||||
prefix_pending = False
|
||||
continue
|
||||
if token.startswith("-"):
|
||||
# Flags belong to the active command. While a wrapper prefix is
|
||||
# waiting for its command (`stdbuf -oL cmd`, `xargs -- cmd`),
|
||||
# keep expect_command intact.
|
||||
if not prefix_pending:
|
||||
expect_command = False
|
||||
continue
|
||||
if not expect_command:
|
||||
continue
|
||||
# FOO=bar prefix: assignment list, next non-assignment token is the command.
|
||||
if _ASSIGNMENT_RE.match(token):
|
||||
continue
|
||||
# `timeout 1 cmd` / `nice -n 5 cmd` style numeric wrapper arg.
|
||||
if prefix_pending and token.lstrip("-").isdigit():
|
||||
continue
|
||||
base = _token_basename(token)
|
||||
if base in _BLOCKED_COMMANDS:
|
||||
blocked.add(base)
|
||||
# Wrappers (`env` / `time` / `xargs` / `sudo`) consume one command; the
|
||||
# next non-flag, non-numeric token is the real command. `sudo` is
|
||||
# already in _BLOCKED_COMMANDS, so it's flagged AND we keep walking.
|
||||
if base in _COMMAND_PREFIXES:
|
||||
prefix_pending = True
|
||||
continue
|
||||
expect_command = False
|
||||
prefix_pending = False
|
||||
|
||||
# 2. Regex: catch blocked words at shell command boundaries
|
||||
# (semicolons, pipes, &&, ||, backticks, $(), <(), subshells, newlines)
|
||||
# Uses a single combined pattern for all blocked words.
|
||||
# Handles optional Unix path prefix (/usr/bin/) and Windows drive
|
||||
# letter prefix (C:\Windows\...\).
|
||||
# `find ... -exec CMD ... ;` and `-execdir CMD ... ;` invoke CMD directly.
|
||||
for i, tok in enumerate(tokens):
|
||||
if tok in _FIND_EXEC_FLAGS and i + 1 < len(tokens):
|
||||
base = _token_basename(tokens[i + 1])
|
||||
if base in _BLOCKED_COMMANDS:
|
||||
blocked.add(base)
|
||||
|
||||
# Regex: blocked words at shell command boundaries that shlex won't see,
|
||||
# e.g. inside an unquoted $(rm -rf), <(rm), backtick chain, or appended to
|
||||
# a separator with no whitespace ("foo;rm"). Anchored to command-position
|
||||
# delimiters; does not match in argument position.
|
||||
lowered = command.lower()
|
||||
if _BLOCKED_COMMANDS:
|
||||
words_alt = "|".join(re.escape(w) for w in sorted(_BLOCKED_COMMANDS))
|
||||
|
|
@ -153,7 +233,7 @@ def _find_blocked_commands(command: str) -> set[str]:
|
|||
)
|
||||
blocked.update(re.findall(pattern, lowered))
|
||||
|
||||
# 3. Check for nested shell invocations (bash -c 'sudo whoami',
|
||||
# Nested shell invocations (bash -c 'sudo whoami',
|
||||
# bash -lc '...', bash --login -c '...', cmd /c '...').
|
||||
# When a -c or /c flag is found, look backwards for a shell name
|
||||
# (skipping intermediate flags like --login, -l, -x) and recursively
|
||||
|
|
@ -194,10 +274,13 @@ def _find_blocked_commands(command: str) -> set[str]:
|
|||
def _build_safe_env(workdir: str) -> dict[str, str]:
|
||||
"""Build a minimal, credential-free environment for sandboxed subprocesses.
|
||||
|
||||
Strips HF_TOKEN, WANDB_API_KEY, AWS_*, GH_TOKEN, LD_PRELOAD, DYLD_*, etc.
|
||||
Preserves the active Python interpreter and virtualenv directories in PATH
|
||||
so that pip, uv, and packages installed in the Studio runtime remain
|
||||
accessible.
|
||||
Whitelist-built from scratch -- the parent process env is NOT inherited.
|
||||
Only PATH / HOME / TMPDIR / LANG / TERM / PYTHONIOENCODING (+ VIRTUAL_ENV
|
||||
or Windows SystemRoot when applicable) reach the child. HF_TOKEN,
|
||||
WANDB_API_KEY, AWS_*, GH_TOKEN, OPENAI_API_KEY, LD_PRELOAD, DYLD_*, and
|
||||
every other parent var are absent by construction. HOME points at the
|
||||
sandbox workdir so HF / wandb / aws SDKs cannot read cached credentials
|
||||
from the operator's real ~/.
|
||||
"""
|
||||
# Start with the directory containing the running Python interpreter
|
||||
# so that subprocess calls to 'python', 'pip', etc. resolve to the
|
||||
|
|
@ -296,7 +379,17 @@ def _sandbox_preexec():
|
|||
except (ValueError, OSError, AttributeError):
|
||||
pass
|
||||
try:
|
||||
_resource.setrlimit(_resource.RLIMIT_NOFILE, (1024, 1024))
|
||||
# Default high enough for multi-shard safetensors mmaps + Python's
|
||||
# own handle count; tunable via env for installs that hit the cap.
|
||||
# Clamp to the inherited hard limit so setrlimit doesn't ValueError
|
||||
# on machines where the parent's hard cap is below the requested
|
||||
# value (would otherwise leave NOFILE at the parent's default).
|
||||
nofile = int(os.environ.get("UNSLOTH_STUDIO_SANDBOX_NOFILE", "16384"))
|
||||
_soft_cur, hard_cur = _resource.getrlimit(_resource.RLIMIT_NOFILE)
|
||||
target = (
|
||||
nofile if hard_cur == _resource.RLIM_INFINITY else min(nofile, hard_cur)
|
||||
)
|
||||
_resource.setrlimit(_resource.RLIMIT_NOFILE, (target, target))
|
||||
except (ValueError, OSError, AttributeError):
|
||||
pass
|
||||
|
||||
|
|
@ -1327,13 +1420,208 @@ def _check_signal_escape_patterns(code: str):
|
|||
return True
|
||||
return False
|
||||
|
||||
def _method_call_is_hf_upload(node: ast.Call) -> bool:
|
||||
"""True for HfApi upload method names on any receiver."""
|
||||
# Bare method-name fallback (`x.upload_file(...)`) is intentionally fuzzy,
|
||||
# but should only fire when huggingface_hub / hf_api is actually imported
|
||||
# somewhere in the snippet -- otherwise paramiko.upload_file, boto3
|
||||
# create_commit, etc. hit a false positive. We pre-scan for the imports.
|
||||
_HF_IMPORT_MODULES = (
|
||||
"huggingface_hub",
|
||||
"hf_api",
|
||||
"huggingface_hub.hf_api",
|
||||
)
|
||||
|
||||
def _module_has_hf_import(tree: ast.AST) -> bool:
|
||||
for n in ast.walk(tree):
|
||||
if isinstance(n, ast.Import):
|
||||
for alias in n.names:
|
||||
if alias.name.split(".", 1)[0] in _HF_IMPORT_MODULES:
|
||||
return True
|
||||
elif isinstance(n, ast.ImportFrom):
|
||||
root = (n.module or "").split(".", 1)[0]
|
||||
if root in _HF_IMPORT_MODULES:
|
||||
return True
|
||||
elif isinstance(n, ast.Call) and n.args:
|
||||
# __import__('huggingface_hub'), importlib.import_module('huggingface_hub'),
|
||||
# and bare import_module('huggingface_hub') (via `from importlib import ...`).
|
||||
arg0 = n.args[0]
|
||||
if not (isinstance(arg0, ast.Constant) and isinstance(arg0.value, str)):
|
||||
continue
|
||||
if arg0.value.split(".", 1)[0] not in _HF_IMPORT_MODULES:
|
||||
continue
|
||||
func = n.func
|
||||
if isinstance(func, ast.Name) and func.id in {
|
||||
"__import__",
|
||||
"import_module",
|
||||
}:
|
||||
return True
|
||||
if isinstance(func, ast.Attribute) and func.attr == "import_module":
|
||||
return True
|
||||
return False
|
||||
|
||||
_hf_in_scope = _module_has_hf_import(tree)
|
||||
|
||||
def _method_call_hf_upload_name(node: ast.Call) -> str | None:
|
||||
"""Return the HF upload method name (`upload_file`, ...) or None.
|
||||
|
||||
Catches `HfApi().upload_file(...)` (Attribute) and
|
||||
`from huggingface_hub import upload_file; upload_file(...)` (Name).
|
||||
The bare-name branch fires only when an HF import is in scope, mirroring
|
||||
the Attribute branch's gating so paramiko/boto3 do not false-positive.
|
||||
"""
|
||||
if not _hf_in_scope:
|
||||
return None
|
||||
f = node.func
|
||||
if isinstance(f, ast.Attribute) and f.attr in _UPLOAD_HF_METHODS:
|
||||
return f.attr
|
||||
if isinstance(f, ast.Name) and f.id in _UPLOAD_HF_METHODS:
|
||||
return f.id
|
||||
return None
|
||||
|
||||
# Kwargs that ship a credential over the wire. Sandbox env strips HF_TOKEN
|
||||
# / WANDB_API_KEY / AWS_* up front, so any value here is hard-coded or
|
||||
# lifted from the parent process.
|
||||
_HF_SENSITIVE_KWARGS = frozenset(
|
||||
{
|
||||
"token",
|
||||
"hf_token",
|
||||
"api_token",
|
||||
"api_key",
|
||||
"auth_token",
|
||||
"access_token",
|
||||
"password",
|
||||
"secret",
|
||||
}
|
||||
)
|
||||
|
||||
def _is_os_environ(node: ast.AST) -> bool:
|
||||
return (
|
||||
isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr in _UPLOAD_HF_METHODS
|
||||
isinstance(node, ast.Attribute)
|
||||
and node.attr == "environ"
|
||||
and isinstance(node.value, ast.Name)
|
||||
and node.value.id == "os"
|
||||
)
|
||||
|
||||
def _reads_env_or_secret(node: ast.AST | None) -> bool:
|
||||
"""True if any node in the subtree resolves to an env / process read.
|
||||
|
||||
Walking the subtree (not just the root) means wrapper calls like
|
||||
`str(os.environ)`, `json.dumps(os.environ)`, or
|
||||
`'-'.join(os.environ.values())` are caught too.
|
||||
|
||||
Covers: `os.environ`, `os.environ[K]`, `os.environ.get(K)`, `os.getenv(K)`,
|
||||
bare `getenv(K)` (after `from os import getenv`), and
|
||||
`subprocess.{run,check_output,Popen,getoutput,getstatusoutput}` which
|
||||
the LLM could use to lift parent env via `printenv` / `env` / `set`.
|
||||
"""
|
||||
if node is None:
|
||||
return False
|
||||
for sub in ast.walk(node):
|
||||
if _is_os_environ(sub):
|
||||
return True
|
||||
if isinstance(sub, ast.Call):
|
||||
f = sub.func
|
||||
if isinstance(f, ast.Attribute):
|
||||
if (
|
||||
f.attr in {"getenv", "getenvb"}
|
||||
and isinstance(f.value, ast.Name)
|
||||
and f.value.id == "os"
|
||||
):
|
||||
return True
|
||||
if (
|
||||
f.attr
|
||||
in {
|
||||
"check_output",
|
||||
"run",
|
||||
"Popen",
|
||||
"getoutput",
|
||||
"getstatusoutput",
|
||||
}
|
||||
and isinstance(f.value, ast.Name)
|
||||
and f.value.id in {"subprocess", "commands"}
|
||||
):
|
||||
return True
|
||||
if isinstance(f, ast.Name) and f.id in {"getenv", "getenvb"}:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_safe_relative_path(path: str) -> bool:
|
||||
"""Relative path with no leading `/`, `~`, drive letter, or `..` segments."""
|
||||
if not isinstance(path, str) or not path:
|
||||
return False
|
||||
if path[0] in ("/", "\\", "~"):
|
||||
return False
|
||||
if len(path) >= 2 and path[1] == ":":
|
||||
return False
|
||||
return ".." not in path.replace("\\", "/").split("/")
|
||||
|
||||
def _path_arg_is_sandbox_local(node: ast.AST | None) -> bool:
|
||||
"""Whether the path argument resolves to a sandbox-local literal."""
|
||||
if node is None:
|
||||
return False
|
||||
if isinstance(node, ast.Constant) and isinstance(
|
||||
node.value, (bytes, bytearray)
|
||||
):
|
||||
return True # inline bytes, no file access
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
return _is_safe_relative_path(node.value)
|
||||
if isinstance(node, ast.Call):
|
||||
f = node.func
|
||||
is_open = (isinstance(f, ast.Name) and f.id == "open") or (
|
||||
isinstance(f, ast.Attribute) and f.attr == "open"
|
||||
)
|
||||
if is_open and node.args:
|
||||
a0 = node.args[0]
|
||||
return (
|
||||
isinstance(a0, ast.Constant)
|
||||
and isinstance(a0.value, str)
|
||||
and _is_safe_relative_path(a0.value)
|
||||
)
|
||||
return False
|
||||
|
||||
def _hf_upload_violation(node: ast.Call, method_name: str) -> str | None:
|
||||
"""Inspect an HF upload call; return a violation reason or None.
|
||||
|
||||
Policy: HF uploads are allowed only when (a) no sensitive kwarg is set,
|
||||
(b) no positional / keyword value reads `os.environ` or related env
|
||||
readers, and (c) the path argument is a sandbox-local literal -- a
|
||||
relative string with no `..`, an `open(<literal>)`, or inline bytes.
|
||||
Dynamic / variable paths are rejected; the policy cannot prove safety
|
||||
statically and the cost of a wrong-allow is a credential exfiltration.
|
||||
"""
|
||||
for kw in node.keywords or []:
|
||||
if kw.arg in _HF_SENSITIVE_KWARGS:
|
||||
return (
|
||||
f"HF upload {kw.arg}= cannot be set from sandboxed code; "
|
||||
"uploads run with the sandbox identity only"
|
||||
)
|
||||
all_values = list(node.args or []) + [kw.value for kw in (node.keywords or [])]
|
||||
for v in all_values:
|
||||
if _reads_env_or_secret(v):
|
||||
return (
|
||||
"HF upload cannot include os.environ / os.getenv / subprocess "
|
||||
"env reads; secrets and tokens must not be exfiltrated"
|
||||
)
|
||||
if method_name == "create_commit":
|
||||
for kw in node.keywords or []:
|
||||
if kw.arg == "operations" and isinstance(kw.value, ast.List):
|
||||
for elt in kw.value.elts:
|
||||
if isinstance(elt, ast.Call):
|
||||
inner = _hf_upload_violation(elt, "upload_file")
|
||||
if inner:
|
||||
return inner
|
||||
return None
|
||||
path_node: ast.AST | None = node.args[0] if node.args else None
|
||||
for kw in node.keywords or []:
|
||||
if kw.arg in ("path_or_fileobj", "folder_path"):
|
||||
path_node = kw.value
|
||||
break
|
||||
if not _path_arg_is_sandbox_local(path_node):
|
||||
return (
|
||||
"HF upload path must be a sandbox-local relative-path literal "
|
||||
"(no absolute paths, no '..' segments, no dynamic expressions)"
|
||||
)
|
||||
return None
|
||||
|
||||
class NetworkAndIoVisitor(ast.NodeVisitor):
|
||||
def visit_Call(self, node):
|
||||
parts: list[str] = []
|
||||
|
|
@ -1345,14 +1633,17 @@ def _check_signal_escape_patterns(code: str):
|
|||
parts.insert(0, cur.id)
|
||||
fq = ".".join(parts) if parts else ""
|
||||
|
||||
if _method_call_is_hf_upload(node):
|
||||
network_calls.append(
|
||||
{
|
||||
"type": "upload_blocked",
|
||||
"line": getattr(node, "lineno", -1),
|
||||
"description": ("Blocked: file upload disallowed in sandbox"),
|
||||
}
|
||||
)
|
||||
hf_upload_name = _method_call_hf_upload_name(node)
|
||||
if hf_upload_name is not None:
|
||||
violation = _hf_upload_violation(node, hf_upload_name)
|
||||
if violation is not None:
|
||||
network_calls.append(
|
||||
{
|
||||
"type": "upload_blocked",
|
||||
"line": getattr(node, "lineno", -1),
|
||||
"description": f"Blocked: {violation}",
|
||||
}
|
||||
)
|
||||
|
||||
# Direct sock.connect((host, port)) bypasses the FQ-prefix branch below.
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -185,33 +185,39 @@ class TestUploadDenylist:
|
|||
expect_phrase = "Blocked: file upload disallowed in sandbox",
|
||||
)
|
||||
|
||||
def test_hf_api_upload_file_blocked(self):
|
||||
_blocked(
|
||||
(
|
||||
"from huggingface_hub import HfApi\n"
|
||||
'HfApi().upload_file(path_or_fileobj="x.bin", '
|
||||
'path_in_repo="x.bin", repo_id="foo/bar")'
|
||||
),
|
||||
expect_phrase = "Blocked: file upload disallowed in sandbox",
|
||||
def test_hf_api_upload_sandbox_local_allowed(self):
|
||||
# Sandbox-local relative path is the canonical safe shape.
|
||||
_ok(
|
||||
"from huggingface_hub import HfApi\n"
|
||||
'HfApi().upload_file(path_or_fileobj="x.bin", '
|
||||
'path_in_repo="x.bin", repo_id="foo/bar")'
|
||||
)
|
||||
|
||||
def test_hf_module_upload_folder_blocked(self):
|
||||
_blocked(
|
||||
(
|
||||
"import huggingface_hub\n"
|
||||
'huggingface_hub.upload_folder(folder_path="./", repo_id="foo/bar")'
|
||||
),
|
||||
expect_phrase = "Blocked: file upload disallowed in sandbox",
|
||||
def test_hf_module_upload_folder_sandbox_local_allowed(self):
|
||||
_ok(
|
||||
"import huggingface_hub\n"
|
||||
'huggingface_hub.upload_folder(folder_path="outputs", repo_id="foo/bar")'
|
||||
)
|
||||
|
||||
def test_hf_create_commit_method_blocked(self):
|
||||
def test_hf_create_commit_empty_operations_allowed(self):
|
||||
_ok(
|
||||
"import huggingface_hub\n"
|
||||
"api = huggingface_hub.HfApi()\n"
|
||||
'api.create_commit(repo_id="foo/bar", operations=[])'
|
||||
)
|
||||
|
||||
def test_hf_upload_absolute_path_blocked(self):
|
||||
_blocked(
|
||||
(
|
||||
"import huggingface_hub\n"
|
||||
"api = huggingface_hub.HfApi()\n"
|
||||
'api.create_commit(repo_id="foo/bar", operations=[])'
|
||||
),
|
||||
expect_phrase = "Blocked: file upload disallowed in sandbox",
|
||||
"from huggingface_hub import HfApi\n"
|
||||
'HfApi().upload_file(path_or_fileobj="/etc/passwd", path_in_repo="x", repo_id="r")',
|
||||
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
|
||||
)
|
||||
|
||||
def test_hf_upload_parent_dir_escape_blocked(self):
|
||||
_blocked(
|
||||
"import huggingface_hub\n"
|
||||
'huggingface_hub.upload_file(path_or_fileobj="../escape.bin", path_in_repo="x", repo_id="r")',
|
||||
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
|
||||
)
|
||||
|
||||
def test_plain_post_json_not_blocked(self):
|
||||
|
|
@ -221,6 +227,103 @@ class TestUploadDenylist:
|
|||
)
|
||||
|
||||
|
||||
class TestSandboxEnvIsolation:
|
||||
"""The sandbox subprocess env is built from a whitelist, not by stripping.
|
||||
|
||||
Confirm every credential-shaped parent var is absent regardless of how the
|
||||
operator's process is configured. Covers Linux/macOS/WSL/Windows shapes.
|
||||
"""
|
||||
|
||||
_SECRET_KEYS = (
|
||||
# HF + ML tooling
|
||||
"HF_TOKEN",
|
||||
"HUGGING_FACE_HUB_TOKEN",
|
||||
"HUGGINGFACEHUB_API_TOKEN",
|
||||
"WANDB_API_KEY",
|
||||
"WANDB_USERNAME",
|
||||
"MLFLOW_TRACKING_TOKEN",
|
||||
"COMET_API_KEY",
|
||||
"NEPTUNE_API_TOKEN",
|
||||
# Generic cloud
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AWS_SESSION_TOKEN",
|
||||
"GCP_SERVICE_ACCOUNT_KEY",
|
||||
"GOOGLE_APPLICATION_CREDENTIALS",
|
||||
"AZURE_STORAGE_KEY",
|
||||
"AZURE_CLIENT_SECRET",
|
||||
# Forge / git / package
|
||||
"GH_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
"GITLAB_TOKEN",
|
||||
"BITBUCKET_TOKEN",
|
||||
"NPM_TOKEN",
|
||||
"PYPI_TOKEN",
|
||||
"CARGO_REGISTRY_TOKEN",
|
||||
# LLM provider
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
"MISTRAL_API_KEY",
|
||||
"COHERE_API_KEY",
|
||||
"TOGETHER_API_KEY",
|
||||
# Loader injection / sudo state
|
||||
"LD_PRELOAD",
|
||||
"LD_LIBRARY_PATH",
|
||||
"DYLD_INSERT_LIBRARIES",
|
||||
"DYLD_LIBRARY_PATH",
|
||||
# Windows
|
||||
"USERPROFILE",
|
||||
"APPDATA",
|
||||
"LOCALAPPDATA",
|
||||
"ProgramData",
|
||||
)
|
||||
|
||||
def test_no_secret_keys_leak_into_sandbox(self, monkeypatch, tmp_path):
|
||||
from core.inference.tools import _build_safe_env
|
||||
|
||||
for key in self._SECRET_KEYS:
|
||||
monkeypatch.setenv(key, f"sentinel-{key}")
|
||||
env = _build_safe_env(str(tmp_path))
|
||||
for key in self._SECRET_KEYS:
|
||||
assert key not in env, f"parent env var {key!r} leaked into sandbox env"
|
||||
|
||||
def test_sandbox_env_is_minimal_whitelist(self, monkeypatch, tmp_path):
|
||||
from core.inference.tools import _build_safe_env
|
||||
|
||||
# Pollute parent env with arbitrary keys
|
||||
for key in ("EVIL", "RANDOM", "ATTACK_VEC", "MY_TOKEN", "X_API_KEY"):
|
||||
monkeypatch.setenv(key, "leak-me")
|
||||
env = _build_safe_env(str(tmp_path))
|
||||
allowed = {
|
||||
"PATH",
|
||||
"HOME",
|
||||
"TMPDIR",
|
||||
"LANG",
|
||||
"TERM",
|
||||
"PYTHONIOENCODING",
|
||||
"VIRTUAL_ENV",
|
||||
"SystemRoot",
|
||||
}
|
||||
extras = set(env.keys()) - allowed
|
||||
assert not extras, f"sandbox env added unexpected keys: {extras}"
|
||||
|
||||
def test_home_points_at_sandbox_workdir(self, tmp_path):
|
||||
from core.inference.tools import _build_safe_env
|
||||
|
||||
env = _build_safe_env(str(tmp_path))
|
||||
assert env["HOME"] == str(tmp_path)
|
||||
assert env["TMPDIR"] == str(tmp_path)
|
||||
|
||||
def test_term_is_dumb(self, tmp_path):
|
||||
from core.inference.tools import _build_safe_env
|
||||
|
||||
# Prevents the sandbox from re-using the operator's TERM (e.g. xterm-256color)
|
||||
# which could trigger color-escape parsing in downstream tools.
|
||||
env = _build_safe_env(str(tmp_path))
|
||||
assert env["TERM"] == "dumb"
|
||||
|
||||
|
||||
class TestSandboxCpuRlimitDefault:
|
||||
"""Pin the default so a regression below 600s without opt-in is caught."""
|
||||
|
||||
|
|
@ -234,8 +337,464 @@ class TestSandboxCpuRlimitDefault:
|
|||
# Explanatory comment retained.
|
||||
assert "CLONE_NEWNET" in src
|
||||
|
||||
def test_nofile_env_tunable(self):
|
||||
src = (_BACKEND_ROOT / "core" / "inference" / "tools.py").read_text()
|
||||
# Parity with the other rlimits: must come from the env, not be hardcoded.
|
||||
assert "UNSLOTH_STUDIO_SANDBOX_NOFILE" in src
|
||||
|
||||
|
||||
class TestMaxBodyDefault:
|
||||
def test_default_is_500_mb(self):
|
||||
src = (_BACKEND_ROOT / "main.py").read_text()
|
||||
assert 'UNSLOTH_STUDIO_MAX_BODY_MB", "500"' in src
|
||||
|
||||
|
||||
class TestBashBlocklistPosition:
|
||||
"""The blocklist must fire at command position only.
|
||||
|
||||
Pre-fix the per-token loop fired on any token, so `grep -r curl .`
|
||||
and `echo source` were rejected. The position-anchored regex plus a
|
||||
shlex-aware command-position-only token check is sufficient.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _find():
|
||||
from core.inference.tools import _find_blocked_commands
|
||||
|
||||
return _find_blocked_commands
|
||||
|
||||
# ---- argument-position: must NOT be blocked ----
|
||||
def test_grep_for_curl_string_allowed(self):
|
||||
assert self._find()("grep -r curl .") == set()
|
||||
|
||||
def test_echo_source_allowed(self):
|
||||
assert self._find()("echo source the data") == set()
|
||||
|
||||
def test_cat_with_word_source_allowed(self):
|
||||
# The 'source' word is an argument to echo; not blocked.
|
||||
# `echo` itself isn't blocked. Only legit allowed tokens here.
|
||||
assert self._find()("cat README.md && echo source") == set()
|
||||
assert "source" not in self._find()("cat README.md && echo source")
|
||||
assert "echo" not in self._find()("cat README.md && echo source")
|
||||
|
||||
def test_ls_path_containing_curl_allowed(self):
|
||||
assert self._find()("ls /usr/bin/curl") == set()
|
||||
|
||||
def test_find_for_wget_string_allowed(self):
|
||||
assert self._find()("find . -name wget") == set()
|
||||
|
||||
def test_quoted_curl_arg_allowed(self):
|
||||
assert self._find()('echo "curl is a tool"') == set()
|
||||
|
||||
# ---- command-position: must be blocked ----
|
||||
def test_bare_rm_blocked(self):
|
||||
assert "rm" in self._find()("rm -rf /")
|
||||
|
||||
def test_curl_at_command_position_blocked(self):
|
||||
assert "curl" in self._find()("curl https://example.com")
|
||||
|
||||
def test_after_semicolon_blocked(self):
|
||||
# `rm` after `;` even without surrounding whitespace.
|
||||
assert "rm" in self._find()("echo done; rm -rf /tmp/x")
|
||||
assert "rm" in self._find()("echo done;rm -rf /tmp/x")
|
||||
|
||||
def test_after_double_ampersand_blocked(self):
|
||||
assert "wget" in self._find()("cd /tmp && wget https://bad")
|
||||
|
||||
def test_split_quotes_obfuscation_blocked(self):
|
||||
# shlex collapses 'r''m' -> 'rm' as a single token at command position.
|
||||
assert "rm" in self._find()("r''m -rf /")
|
||||
|
||||
def test_path_prefixed_command_blocked(self):
|
||||
assert "sudo" in self._find()("/usr/bin/sudo whoami")
|
||||
|
||||
def test_nested_bash_c_blocked(self):
|
||||
# Recursion into the nested command string still catches command-position curl.
|
||||
assert "curl" in self._find()("bash -c 'curl https://x'")
|
||||
|
||||
def test_subshell_command_blocked(self):
|
||||
assert "rm" in self._find()("echo $(rm -rf /tmp)")
|
||||
|
||||
def test_backtick_command_blocked(self):
|
||||
assert "rm" in self._find()("echo `rm -rf /tmp`")
|
||||
|
||||
# ---- shell prefixes / wrappers: must still be blocked ----
|
||||
@pytest.mark.parametrize(
|
||||
"command, blocked_cmd",
|
||||
[
|
||||
("FOO=bar curl https://example.com", "curl"),
|
||||
("HTTPS_PROXY=http://x wget https://bad", "wget"),
|
||||
("env curl https://example.com", "curl"),
|
||||
("env FOO=1 /usr/bin/curl https://x", "curl"),
|
||||
("/usr/bin/env rm -rf /tmp/x", "rm"),
|
||||
("command rm -rf /tmp/x", "rm"),
|
||||
("time curl https://example.com", "curl"),
|
||||
("nice rm -rf /tmp/x", "rm"),
|
||||
("nohup wget https://bad", "wget"),
|
||||
("timeout 1 rm -rf /tmp/x", "rm"),
|
||||
("setsid rm -rf /tmp/x", "rm"),
|
||||
("stdbuf -oL rm -rf /tmp/x", "rm"),
|
||||
("sudo rm -rf /tmp/x", "rm"),
|
||||
("cd /tmp; FOO=bar rm -rf x", "rm"),
|
||||
],
|
||||
)
|
||||
def test_command_prefix_wrappers_blocked(self, command, blocked_cmd):
|
||||
assert blocked_cmd in self._find()(command)
|
||||
|
||||
# ---- split-quoted command name after attached separators ----
|
||||
def test_split_quotes_after_semicolon_blocked(self):
|
||||
assert "rm" in self._find()("echo done; r''m -rf /tmp/x")
|
||||
assert "rm" in self._find()("echo done;r''m -rf /tmp/x")
|
||||
assert "curl" in self._find()("echo done; c''url --version")
|
||||
assert "curl" in self._find()("echo done; /usr/bin/c''url --version")
|
||||
|
||||
# ---- find -exec / xargs invoke a command directly ----
|
||||
def test_find_exec_blocked(self):
|
||||
assert "rm" in self._find()("find . -type f -exec rm -f {} +")
|
||||
assert "rm" in self._find()("find . -type f -exec rm -f {} ';'")
|
||||
assert "rm" in self._find()("find . -execdir rm -f {} ';'")
|
||||
|
||||
def test_xargs_command_blocked(self):
|
||||
assert "rm" in self._find()("printf /tmp/x | xargs rm")
|
||||
assert "rm" in self._find()("printf /tmp/x | xargs -- rm")
|
||||
|
||||
# ---- brace groups and bash compound statements ----
|
||||
def test_brace_group_blocked(self):
|
||||
assert "rm" in self._find()("{ rm -rf /tmp/x; }")
|
||||
|
||||
def test_if_then_blocked(self):
|
||||
assert "curl" in self._find()("if true; then curl --version; fi")
|
||||
|
||||
def test_while_do_blocked(self):
|
||||
assert "curl" in self._find()("while true; do curl --version; break; done")
|
||||
|
||||
|
||||
class TestHfUploadImportGate:
|
||||
"""HfApi-style upload-method blocking should require an HF import in
|
||||
scope; otherwise paramiko / boto3 / internal SDKs with the same
|
||||
method names hit a false positive."""
|
||||
|
||||
def test_paramiko_upload_file_allowed_without_hf_import(self):
|
||||
_ok("import paramiko; sftp=None; sftp.upload_file('a','b')")
|
||||
|
||||
def test_boto3_create_commit_allowed_without_hf_import(self):
|
||||
_ok("client=None; client.create_commit(Repo='x')")
|
||||
|
||||
def test_hf_api_upload_safe_path_allowed(self):
|
||||
# Sandbox-local relative path -- the call shape we want to permit.
|
||||
_ok("from huggingface_hub import HfApi; HfApi().upload_file('a','b','c')")
|
||||
|
||||
def test_hf_upload_file_fq_safe_path_allowed(self):
|
||||
_ok("import huggingface_hub; huggingface_hub.upload_file('a','b','c')")
|
||||
|
||||
def test_dynamic_builtin_import_safe_path_allowed(self):
|
||||
# `__import__('huggingface_hub')` puts HF in scope; relative-literal path is safe.
|
||||
_ok("hf=__import__('huggingface_hub'); hf.HfApi().upload_file('a','b','c')")
|
||||
|
||||
def test_dynamic_importlib_safe_path_allowed(self):
|
||||
_ok(
|
||||
"import importlib; hf=importlib.import_module('huggingface_hub');"
|
||||
" hf.HfApi().upload_file('a','b','c')"
|
||||
)
|
||||
|
||||
def test_from_importlib_import_module_safe_create_commit_allowed(self):
|
||||
_ok(
|
||||
"from importlib import import_module;"
|
||||
" api=import_module('huggingface_hub').HfApi(); api.create_commit()"
|
||||
)
|
||||
|
||||
def test_hf_bare_name_upload_safe_path_allowed(self):
|
||||
# `from huggingface_hub import upload_file` then bare `upload_file(...)`
|
||||
# with a sandbox-local relative-path literal is allowed.
|
||||
_ok(
|
||||
"from huggingface_hub import upload_file;"
|
||||
" upload_file(path_or_fileobj='x', path_in_repo='x', repo_id='r')"
|
||||
)
|
||||
|
||||
def test_hf_bare_name_upload_folder_safe_allowed(self):
|
||||
_ok(
|
||||
"from huggingface_hub import upload_folder;"
|
||||
" upload_folder(folder_path='x', repo_id='r')"
|
||||
)
|
||||
|
||||
def test_hf_bare_name_create_commit_safe_allowed(self):
|
||||
_ok(
|
||||
"from huggingface_hub import create_commit;"
|
||||
" create_commit(operations=[], repo_id='r')"
|
||||
)
|
||||
|
||||
def test_bare_name_upload_file_without_hf_import_allowed(self):
|
||||
# No HF import -- local helper named upload_file should pass.
|
||||
_ok("def upload_file(*a, **k):\n pass\n" "upload_file('x', 'y', 'z')")
|
||||
|
||||
|
||||
class TestHfUploadSandboxLocalPaths:
|
||||
"""The HF upload gate must only allow uploads of files that already live in
|
||||
the sandbox workdir. Absolute paths, `..` traversal, home expansion, and
|
||||
Windows drive letters are rejected because the LLM can use them to lift
|
||||
secrets from outside the sandbox."""
|
||||
|
||||
def test_relative_literal_allowed(self):
|
||||
_ok(
|
||||
"import huggingface_hub\n"
|
||||
'huggingface_hub.upload_file(path_or_fileobj="model.bin",'
|
||||
' path_in_repo="model.bin", repo_id="me/r")'
|
||||
)
|
||||
|
||||
def test_dotted_relative_allowed(self):
|
||||
_ok(
|
||||
"import huggingface_hub\n"
|
||||
'huggingface_hub.upload_file(path_or_fileobj="./outputs/m.bin",'
|
||||
' path_in_repo="m.bin", repo_id="me/r")'
|
||||
)
|
||||
|
||||
def test_nested_relative_allowed(self):
|
||||
_ok(
|
||||
"import huggingface_hub\n"
|
||||
'huggingface_hub.upload_file(path_or_fileobj="outputs/run42/model.bin",'
|
||||
' path_in_repo="m.bin", repo_id="me/r")'
|
||||
)
|
||||
|
||||
def test_open_of_relative_literal_allowed(self):
|
||||
_ok(
|
||||
"import huggingface_hub\n"
|
||||
'huggingface_hub.upload_file(path_or_fileobj=open("model.bin", "rb"),'
|
||||
' path_in_repo="m.bin", repo_id="me/r")'
|
||||
)
|
||||
|
||||
def test_inline_bytes_literal_allowed(self):
|
||||
_ok(
|
||||
"import huggingface_hub\n"
|
||||
'huggingface_hub.upload_file(path_or_fileobj=b"\\x00\\x01\\x02",'
|
||||
' path_in_repo="m.bin", repo_id="me/r")'
|
||||
)
|
||||
|
||||
def test_absolute_unix_path_blocked(self):
|
||||
_blocked(
|
||||
"import huggingface_hub\n"
|
||||
'huggingface_hub.upload_file(path_or_fileobj="/etc/passwd",'
|
||||
' path_in_repo="x", repo_id="r")',
|
||||
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
|
||||
)
|
||||
|
||||
def test_absolute_windows_drive_blocked(self):
|
||||
_blocked(
|
||||
"import huggingface_hub\n"
|
||||
'huggingface_hub.upload_file(path_or_fileobj="C:\\\\Windows\\\\creds",'
|
||||
' path_in_repo="x", repo_id="r")',
|
||||
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
|
||||
)
|
||||
|
||||
def test_home_expansion_blocked(self):
|
||||
_blocked(
|
||||
"import huggingface_hub\n"
|
||||
'huggingface_hub.upload_file(path_or_fileobj="~/.aws/credentials",'
|
||||
' path_in_repo="x", repo_id="r")',
|
||||
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
|
||||
)
|
||||
|
||||
def test_parent_traversal_blocked(self):
|
||||
_blocked(
|
||||
"import huggingface_hub\n"
|
||||
'huggingface_hub.upload_file(path_or_fileobj="../../etc/shadow",'
|
||||
' path_in_repo="x", repo_id="r")',
|
||||
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
|
||||
)
|
||||
|
||||
def test_parent_traversal_mid_path_blocked(self):
|
||||
_blocked(
|
||||
"import huggingface_hub\n"
|
||||
'huggingface_hub.upload_file(path_or_fileobj="outputs/../../../etc",'
|
||||
' path_in_repo="x", repo_id="r")',
|
||||
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
|
||||
)
|
||||
|
||||
def test_open_of_absolute_blocked(self):
|
||||
_blocked(
|
||||
"import huggingface_hub\n"
|
||||
'huggingface_hub.upload_file(path_or_fileobj=open("/etc/passwd","rb"),'
|
||||
' path_in_repo="x", repo_id="r")',
|
||||
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
|
||||
)
|
||||
|
||||
def test_open_of_parent_traversal_blocked(self):
|
||||
_blocked(
|
||||
"import huggingface_hub\n"
|
||||
'huggingface_hub.upload_file(path_or_fileobj=open("../escape","rb"),'
|
||||
' path_in_repo="x", repo_id="r")',
|
||||
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
|
||||
)
|
||||
|
||||
def test_dynamic_variable_path_blocked(self):
|
||||
# A non-literal expression could resolve to any path at runtime;
|
||||
# the static checker cannot prove safety, so block.
|
||||
_blocked(
|
||||
"import huggingface_hub, os\n"
|
||||
"p = os.path.join('outputs', 'x.bin')\n"
|
||||
'huggingface_hub.upload_file(path_or_fileobj=p, path_in_repo="x", repo_id="r")',
|
||||
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
|
||||
)
|
||||
|
||||
def test_upload_folder_absolute_blocked(self):
|
||||
_blocked(
|
||||
"import huggingface_hub\n"
|
||||
'huggingface_hub.upload_folder(folder_path="/var/log", repo_id="r")',
|
||||
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
|
||||
)
|
||||
|
||||
def test_upload_folder_parent_traversal_blocked(self):
|
||||
_blocked(
|
||||
"import huggingface_hub\n"
|
||||
'huggingface_hub.upload_folder(folder_path="../..", repo_id="r")',
|
||||
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
|
||||
)
|
||||
|
||||
def test_upload_large_folder_absolute_blocked(self):
|
||||
_blocked(
|
||||
"import huggingface_hub\n"
|
||||
'huggingface_hub.upload_large_folder(folder_path="/etc", repo_id="r")',
|
||||
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
|
||||
)
|
||||
|
||||
def test_create_commit_operation_safe_allowed(self):
|
||||
_ok(
|
||||
"import huggingface_hub\n"
|
||||
"from huggingface_hub import CommitOperationAdd\n"
|
||||
"huggingface_hub.HfApi().create_commit(\n"
|
||||
" repo_id='r',\n"
|
||||
" operations=[CommitOperationAdd(path_or_fileobj='m.bin', path_in_repo='m.bin')],\n"
|
||||
")"
|
||||
)
|
||||
|
||||
def test_create_commit_operation_absolute_blocked(self):
|
||||
_blocked(
|
||||
"import huggingface_hub\n"
|
||||
"from huggingface_hub import CommitOperationAdd\n"
|
||||
"huggingface_hub.HfApi().create_commit(\n"
|
||||
" repo_id='r',\n"
|
||||
" operations=[CommitOperationAdd(path_or_fileobj='/etc/passwd', path_in_repo='x')],\n"
|
||||
")",
|
||||
expect_phrase = "HF upload path must be a sandbox-local relative-path literal",
|
||||
)
|
||||
|
||||
|
||||
class TestHfUploadEnvAndSecretLeakBlock:
|
||||
"""The HF upload gate must reject any positional / keyword arg sourced from
|
||||
`os.environ` / `os.getenv` / subprocess env reads. Even though
|
||||
`_build_safe_env` strips HF_TOKEN/WANDB/AWS upfront for the sandbox shell,
|
||||
a Python script can still reach the parent process env if it bypasses the
|
||||
safe-env wrapper at the source -- so block statically."""
|
||||
|
||||
def test_path_from_os_environ_subscript_blocked(self):
|
||||
_blocked(
|
||||
"import huggingface_hub, os\n"
|
||||
'huggingface_hub.upload_file(path_or_fileobj=os.environ["HF_TOKEN"],'
|
||||
' path_in_repo="x", repo_id="r")',
|
||||
expect_phrase = "HF upload cannot include os.environ",
|
||||
)
|
||||
|
||||
def test_path_from_os_environ_get_blocked(self):
|
||||
_blocked(
|
||||
"import huggingface_hub, os\n"
|
||||
'huggingface_hub.upload_file(path_or_fileobj=os.environ.get("HF_TOKEN"),'
|
||||
' path_in_repo="x", repo_id="r")',
|
||||
expect_phrase = "HF upload cannot include os.environ",
|
||||
)
|
||||
|
||||
def test_path_from_os_getenv_blocked(self):
|
||||
_blocked(
|
||||
"import huggingface_hub, os\n"
|
||||
'huggingface_hub.upload_file(path_or_fileobj=os.getenv("HF_TOKEN"),'
|
||||
' path_in_repo="x", repo_id="r")',
|
||||
expect_phrase = "HF upload cannot include os.environ",
|
||||
)
|
||||
|
||||
def test_path_from_bare_getenv_blocked(self):
|
||||
_blocked(
|
||||
"import huggingface_hub\n"
|
||||
"from os import getenv\n"
|
||||
'huggingface_hub.upload_file(path_or_fileobj=getenv("HF_TOKEN"),'
|
||||
' path_in_repo="x", repo_id="r")',
|
||||
expect_phrase = "HF upload cannot include os.environ",
|
||||
)
|
||||
|
||||
def test_path_from_subprocess_printenv_blocked(self):
|
||||
_blocked(
|
||||
"import huggingface_hub, subprocess\n"
|
||||
"huggingface_hub.upload_file("
|
||||
'path_or_fileobj=subprocess.check_output(["printenv","HF_TOKEN"]),'
|
||||
' path_in_repo="x", repo_id="r")',
|
||||
expect_phrase = "HF upload cannot include os.environ",
|
||||
)
|
||||
|
||||
def test_token_kwarg_with_literal_blocked(self):
|
||||
_blocked(
|
||||
"import huggingface_hub\n"
|
||||
'huggingface_hub.upload_file(path_or_fileobj="x.bin",'
|
||||
' path_in_repo="x", repo_id="r", token="hf_xyzabc123")',
|
||||
expect_phrase = "HF upload token= cannot be set",
|
||||
)
|
||||
|
||||
def test_hf_token_kwarg_blocked(self):
|
||||
_blocked(
|
||||
"import huggingface_hub\n"
|
||||
'huggingface_hub.upload_file(path_or_fileobj="x.bin",'
|
||||
' path_in_repo="x", repo_id="r", hf_token="hf_secret")',
|
||||
expect_phrase = "HF upload hf_token= cannot be set",
|
||||
)
|
||||
|
||||
def test_api_key_kwarg_blocked(self):
|
||||
_blocked(
|
||||
"import huggingface_hub\n"
|
||||
'huggingface_hub.upload_folder(folder_path="outputs",'
|
||||
' repo_id="r", api_key="abc")',
|
||||
expect_phrase = "HF upload api_key= cannot be set",
|
||||
)
|
||||
|
||||
def test_token_kwarg_from_env_blocked(self):
|
||||
# Both rules fire; the sensitive-kwarg check trips first.
|
||||
_blocked(
|
||||
"import huggingface_hub, os\n"
|
||||
'huggingface_hub.upload_file(path_or_fileobj="x.bin",'
|
||||
' path_in_repo="x", repo_id="r", token=os.environ["HF_TOKEN"])',
|
||||
expect_phrase = "HF upload token= cannot be set",
|
||||
)
|
||||
|
||||
def test_env_dict_unpacked_via_environ_attr_blocked(self):
|
||||
# `os.environ` as a bare reference (passed somewhere it gets serialized).
|
||||
_blocked(
|
||||
"import huggingface_hub, os\n"
|
||||
"huggingface_hub.upload_file(path_or_fileobj=str(os.environ),"
|
||||
' path_in_repo="x", repo_id="r")',
|
||||
expect_phrase = "HF upload cannot include os.environ",
|
||||
)
|
||||
|
||||
def test_repo_id_from_env_also_blocked(self):
|
||||
# Even non-path args must not source env vars -- an attacker could
|
||||
# encode secrets in repo_id or path_in_repo.
|
||||
_blocked(
|
||||
"import huggingface_hub, os\n"
|
||||
'huggingface_hub.upload_file(path_or_fileobj="x.bin",'
|
||||
' path_in_repo=os.environ["HF_TOKEN"], repo_id="r")',
|
||||
expect_phrase = "HF upload cannot include os.environ",
|
||||
)
|
||||
|
||||
def test_create_commit_with_env_in_operation_blocked(self):
|
||||
_blocked(
|
||||
"import huggingface_hub, os\n"
|
||||
"from huggingface_hub import CommitOperationAdd\n"
|
||||
"huggingface_hub.HfApi().create_commit(\n"
|
||||
" repo_id='r',\n"
|
||||
" operations=[CommitOperationAdd("
|
||||
'path_or_fileobj=os.environ["HF_TOKEN"], path_in_repo="x")],\n'
|
||||
")",
|
||||
expect_phrase = "HF upload cannot include os.environ",
|
||||
)
|
||||
|
||||
def test_create_commit_token_kwarg_blocked(self):
|
||||
_blocked(
|
||||
"import huggingface_hub\n"
|
||||
'huggingface_hub.HfApi().create_commit(repo_id="r",'
|
||||
' operations=[], token="hf_xxx")',
|
||||
expect_phrase = "HF upload token= cannot be set",
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue