diff --git a/CHANGELOG.md b/CHANGELOG.md index d2e59cdac5..d2fbcc7f6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6022,6 +6022,26 @@ This release closes that milestone with **621 merged pull requests**. names the same file as the equivalent relative path, unless the command rebinds `PWD` itself. Variables inside URLs are unaffected. The guard remains best-effort and is not an isolation boundary. ([#6930]) +- **sandbox:** With host bash enabled, the local path guard accepts awk + field arithmetic again, such as `awk '{print $NF/2}' data.txt`, + `awk '{print $sum/$n}' data.txt` and `($2+$3)/2`. Since [#6930] every + `$NAME/` was treated as a variable-prefixed host path unless both sides were + digits, and a `/` after `)` had always read as an absolute path. A `$` or + `/` inside the single-quoted inline program of `awk`, `gawk`, `mawk` or + `nawk` (after options such as `-F` and `-v`, found the way the shell finds + the command word: after an assignment prefix, with a path reduced to its + basename, inside `$(...)`) is now exempt, because bash expands nothing there + and awk reads it as a field reference or division; for the absolute-path + scan only a single-segment match such as `/2` or `/NR` is exempt, so a + `/a/b` shape stays rejected even inside awk code. Text inside awk string + literals, options such as `-v`, input operands, double-quoted programs and + text re-read by `sh -c` or `eval` are still scanned. No exemption is granted + to a command that could redefine `awk` or re-read quoted text (`alias`, + `function`, `name()`, `eval`, `source`, `.`, `hash`, `PATH=`, a heredoc), nor + to syntax the scanner does not model (a backslash-newline, backticks, + `$'...'`, an unmatched `)`), nor to a program whose `"` may sit in an awk + regex literal; `/` and `#` inside an awk string literal are plain text. + (issue #7021) - **sandbox:** On the local sandbox, `ls` now accepts the `/mnt/user-data` root itself and lists `workspace`, `uploads` and `outputs` as it does on AIO. The provider maps that root on purpose, but the tool layer allowed diff --git a/backend/packages/harness/deerflow/sandbox/tools.py b/backend/packages/harness/deerflow/sandbox/tools.py index 48c9bb483e..8b4ac8b74f 100644 --- a/backend/packages/harness/deerflow/sandbox/tools.py +++ b/backend/packages/harness/deerflow/sandbox/tools.py @@ -6,6 +6,7 @@ import os import posixpath import re import shlex +import string from collections.abc import AsyncIterator, Callable, Iterator from contextlib import asynccontextmanager, contextmanager from contextvars import ContextVar @@ -78,6 +79,10 @@ _ABSOLUTE_PATH_PATTERN = re.compile(r"(?()]+)") # through the absolute-path regex because ``}``, ``~``, ``@`` and ``?`` are not # word chars. The remainder is captured in a lookahead so a skipped prefix does # not hide a later one (``$1/$2/etc/passwd``). +# The one structural exemption is a ``$`` inside the single-quoted inline +# program of an awk-family command (``awk '{print $NF/2}'``): bash expands +# nothing there and awk reads ``$NAME`` as a field reference. See +# ``_awk_program_field_spans`` for what keeps that exemption narrow. _SHELL_VARIABLE_PATH_PATTERN = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*|[0-9])/(?=([^\s\"'`;&|<>()]*))") # ``~name/`` expands to that user's home (``~root/.ssh/id_rsa``) and slips the # absolute-path regex for the same reason. Tilde expansion only applies at the @@ -1179,6 +1184,334 @@ def _split_shell_tokens(command: str) -> list[str]: _SHELL_PATH_HARD_TERMINATORS = frozenset("\"'`;&|<>()") +_AWK_COMMAND_NAMES = frozenset({"awk", "gawk", "mawk", "nawk"}) +# awk options whose value is the next word. ``-f`` is handled separately: it +# means there is no inline program, so the first operand is an input file. Any +# other option (gawk's ``--assign``, ``-e``, ...) may also consume the next +# word, so it forfeits the exemption. +_AWK_OPTIONS_WITH_VALUE = frozenset({"-F", "-v"}) +# Command words that can make a later ``awk`` word run something other than +# awk, or re-read quoted text: definitions (``alias``, ``function``, ``name()`` +# handled in the walk), ``eval``, sourcing, ``hash -p``, ``enable -f`` (loads a +# builtin under any name) and a ``PATH`` rebind. +# Any of them anywhere in the command forfeits the exemption for the whole +# command; the words are compared after basename reduction. +_AWK_EXEMPTION_BLOCKING_COMMANDS = frozenset({".", "alias", "autoload", "enable", "eval", "function", "hash", "source"}) +# ``name[...]=`` at a command start is never an environment prefix for awk, but +# zsh's ``functions[awk]=...`` / ``aliases[awk]=...`` define a function or alias. +_SHELL_ARRAY_ASSIGNMENT_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\[") +# awk keywords after which ``/`` opens a regex literal (``print /re/``), and the +# keywords whose closing ``)`` is followed by a pattern (``if (x) /re/``) rather +# than a divisor (``($2+$3)/2``). +_AWK_REGEX_KEYWORDS = frozenset({"case", "delete", "do", "else", "exit", "getline", "in", "print", "printf", "return"}) +_AWK_CONDITION_KEYWORDS = frozenset({"for", "if", "while"}) +_AWK_WORD_CHARS = frozenset(string.ascii_letters + string.digits + "_") +_SHELL_WORD_OPERATOR_CHARS = frozenset(";&|()<>\n\r") + + +def _awk_word_before(program: str, position: int) -> str | None: + """The identifier, number or ``]`` that ends just before *position*, or None. + + Scans backward over spaces and tabs only, so the token must sit on the same + line. Returns ``"]"`` or ``")"`` for a closing bracket, the word for an + identifier or number, and None for anything else (operators, punctuation, + start of line). + """ + end = position + while end > 0 and program[end - 1] in " \t": + end -= 1 + if end == 0: + return None + if program[end - 1] in "])": + return program[end - 1] + start = end + while start > 0 and program[start - 1] in _AWK_WORD_CHARS: + start -= 1 + return program[start:end] or None + + +def _awk_program_code_spans(program: str, offset: int) -> list[tuple[int, int]] | None: + """Spans of *program* (shifted by *offset*) that awk reads as code, not string text. + + A ``$NAME/`` in awk code is a field reference; inside a string literal it + is text that ``system("...")`` or ``getline < "..."`` may hand to a shell, + so string literals are left out. Rather than lex awk fully, walk the + program once: a ``"`` toggles a string literal (``\\`` escapes inside), + ``#`` outside one starts a comment that runs to the end of the line, and + inside a literal ``/`` and ``#`` are plain text (``split($0, p, "/")``). + A ``/`` outside a literal must be division, which in awk follows an + identifier, number, ``]`` or a ``)`` that does not close an ``if``, + ``while`` or ``for`` condition, all on the same line. Any other ``/`` + (``gsub(/"/, ...)``, ``if (x) /re/``, a ``/`` at the start of a line) + opens a regex literal whose ``"`` would desynchronise the tracker, so + return None and the caller grants no exemption. + """ + spans: list[tuple[int, int]] = [] + in_string = False + segment_start = 0 + open_parens: list[int] = [] + last_closed_opener: int | None = None + position = 0 + while position < len(program): + char = program[position] + if in_string: + if char == "\\": + position += 1 + elif char == '"': + in_string = False + segment_start = position + 1 + elif char == '"': + spans.append((offset + segment_start, offset + position)) + in_string = True + elif char == "#": + newline = program.find("\n", position) + position = len(program) if newline == -1 else newline + continue + elif char == "(": + open_parens.append(position) + elif char == ")": + last_closed_opener = open_parens.pop() if open_parens else None + elif char == "/": + word = _awk_word_before(program, position) + if word == ")": + if last_closed_opener is None or _awk_word_before(program, last_closed_opener) in _AWK_CONDITION_KEYWORDS: + return None + elif word is None or word in _AWK_REGEX_KEYWORDS: + return None + position += 1 + if not in_string: + spans.append((offset + segment_start, offset + len(program))) + return spans + + +class _ShellWord: + """One word of a scanned shell command. + + ``text`` is the word with quotes removed; ``single_quoted`` is the span of + the word's content in the original command when the whole word is exactly + one single-quoted string, else None. ``kind`` is ``"word"``, ``"op"`` (a + command separator, including ``(`` and ``)``) or ``"redir"``. + """ + + __slots__ = ("kind", "single_quoted", "text") + + def __init__(self, kind: str, text: str, single_quoted: tuple[int, int] | None = None) -> None: + self.kind = kind + self.text = text + self.single_quoted = single_quoted + + +def _scan_shell_words(command: str) -> list[list[_ShellWord]] | None: + """Split *command* into word lists, one per command context, bash-style. + + The first list is the top-level command; every ``$(...)`` substitution adds + its own list, since quoting starts afresh inside it and its first word is + a command. Single quotes, double quotes with ``\\`` escapes, ``\\x`` + escapes, ``#`` comments at the start of a word, ``2>``-style redirections + and the ``;``, ``&&``, ``||``, ``|``, ``|&``, ``&``, ``(``, ``)`` and + newline separators follow bash. Returns None, so that no exemption is + granted, for anything it does not model: a backslash-newline (bash splices + the lines before parsing, which can spell ``awk`` across lines), backticks, + ``$'...'`` strings, a heredoc, an unmatched ``)`` and unbalanced quotes. + """ + if "\\\n" in command or "\\\r" in command: + return None + contexts: list[list[_ShellWord]] = [] + + def scan(position: int, nested: bool) -> int | None: + items: list[_ShellWord] = [] + contexts.append(items) + text: list[str] = [] + quoted_spans: list[tuple[int, int]] = [] + in_word = False + plain_so_far = True # no quotes or substitutions seen in this word yet + depth = 0 + n = len(command) + + def flush() -> None: + nonlocal text, quoted_spans, in_word, plain_so_far + if in_word: + joined = "".join(text) + span = quoted_spans[0] if len(quoted_spans) == 1 and quoted_spans[0][1] - quoted_spans[0][0] == len(joined) else None + items.append(_ShellWord("word", joined, span)) + text, quoted_spans, in_word, plain_so_far = [], [], False, True + + while position < n: + char = command[position] + if char in " \t": + flush() + position += 1 + elif char == "#" and not in_word: + newline = command.find("\n", position) + position = n if newline == -1 else newline + elif char in _SHELL_WORD_OPERATOR_CHARS: + if char in "<>" and in_word and plain_so_far and "".join(text).isdigit(): + text, in_word = [], False # ``2>``: the digits belong to the operator + flush() + if char == ")": + if depth == 0: + return position + 1 if nested else None + depth -= 1 + items.append(_ShellWord("op", ")")) + position += 1 + continue + if char == "(": + depth += 1 + end = position + 1 + group = "&|" if char in "&|" else "<>&|" if char in "<>" else "" + while end < n and command[end] in group: + end += 1 + operator = command[position:end] + if operator.startswith("<<") and not operator.startswith("<<<"): + return None # heredoc bodies are re-read by the shell + items.append(_ShellWord("redir" if char in "<>" else "op", operator)) + position = end + elif char == "`": + return None + elif char == "'": + end = command.find("'", position + 1) + if end == -1: + return None + quoted_spans.append((position + 1, end)) + text.append(command[position + 1 : end]) + in_word, plain_so_far = True, False + position = end + 1 + elif char == '"': + position += 1 + while position < n and command[position] != '"': + if command[position] == "\\": + position += 2 + elif command[position] == "`": + return None + elif command.startswith("$(", position): + after = scan(position + 2, True) + if after is None: + return None + position = after + else: + position += 1 + if position >= n: + return None + text.append("\x00") # opaque: content is never a bare awk program + in_word, plain_so_far = True, False + position += 1 + elif command.startswith("$'", position): + return None + elif command.startswith("$(", position): + after = scan(position + 2, True) + if after is None: + return None + text.append("\x00") + in_word, plain_so_far = True, False + position = after + elif char == "\\": + if position + 1 >= n: + return None + text.append(command[position + 1]) + in_word, plain_so_far = True, False + position += 2 + else: + text.append(char) + in_word = True + position += 1 + flush() + if nested or depth: + return None + return position + + if scan(0, False) is None: + return None + return contexts + + +def _awk_inline_program_span(items: list[_ShellWord], index: int) -> tuple[tuple[int, int] | None, int]: + """Return the span of a wholly single-quoted awk program and the next index.""" + while index < len(items): + item = items[index] + if item.kind == "op": + break + if item.kind == "redir" or item.text in _AWK_OPTIONS_WITH_VALUE: + index += 2 + continue + if item.text == "--file" or item.text.startswith(("-f", "--file=")): + return None, index + 1 + if item.text == "--": + index += 1 + break + if not item.text.startswith("-") or item.text == "-": + break + if not item.text.startswith(("-F", "-v")): + return None, index + 1 + index += 1 + if index < len(items) and items[index].kind == "word" and items[index].single_quoted is not None: + return items[index].single_quoted, index + 1 + return None, index + 1 + + +def _awk_program_field_spans(command: str) -> list[tuple[int, int]]: + """Spans where ``$NAME/`` is an awk field reference rather than a path. + + Only the single-quoted inline program of an awk-family command qualifies: + bash performs no expansion inside single quotes and awk reads ``$`` as its + field operator. The command word is found the way + ``_validate_local_bash_shell_tokens`` finds it (after separators, + assignments, prefix keywords and ``command``/``builtin``, reduced to its + basename), at the top level and inside every ``$(...)``. Text + re-interpreted by another shell (``sh -c '...'``, ``eval``, double-quoted + programs) never qualifies, because the command word there is not awk. The + exemption assumes the word ``awk`` runs the real awk, so a command that + defines or could redefine commands (``alias``, ``function``, ``name()``, + ``autoload``, zsh's ``functions[...]=`` / ``aliases[...]=`` arrays, + ``eval``, ``source``, ``hash``, ``enable``, ``PATH=``) gets no exemption + at all. That + list is best-effort, like the rest of this guard. Inside the program, awk string + literals are left out: ``system("cat $HOME/x")`` hands that text to a + shell. A program whose ``"`` may sit in a regex literal gets no exemption. + """ + contexts = _scan_shell_words(command) + if contexts is None: + return [] + + field_spans: list[tuple[int, int]] = [] + for items in contexts: + at_command_start = True + index = 0 + while index < len(items): + item = items[index] + if item.text.startswith("PATH=") or (index + 1 < len(items) and items[index + 1].kind == "op" and items[index + 1].text.startswith("(")): + return [] # PATH rebind or ``name ()`` function definition + if at_command_start and item.kind == "word" and _SHELL_ARRAY_ASSIGNMENT_PATTERN.match(item.text): + return [] # zsh ``functions[awk]=...`` / ``aliases[awk]=...`` + if item.kind == "op": + at_command_start = True + index += 1 + continue + if item.kind == "redir": + index += 1 if item.text.endswith("&") and item.text not in ("&>", "&>>") else 2 + continue + if not at_command_start: + index += 1 + continue + if _is_shell_assignment(item.text): + index += 1 + continue + name = item.text.rsplit("/", 1)[-1] + if name in _LOCAL_BASH_COMMAND_PREFIX_KEYWORDS or name in _LOCAL_BASH_COMMAND_END_KEYWORDS or name in _LOCAL_BASH_COMMAND_WRAPPERS: + index += 1 + continue + if name in _AWK_EXEMPTION_BLOCKING_COMMANDS: + return [] + at_command_start = False + if name not in _AWK_COMMAND_NAMES: + index += 1 + continue + program, index = _awk_inline_program_span(items, index + 1) + if program is None: + continue + start, end = program + field_spans.extend(_awk_program_code_spans(command[start:end], start) or []) + return field_spans + def _absolute_path_token_occurrences(command: str, tokens: list[str]) -> list[tuple[int, int, str]]: """Locate contiguous occurrences of path-bearing shell tokens in *command*. @@ -1472,7 +1805,11 @@ def validate_local_bash_command_paths(command: str, thread_data: ThreadDataState validated whole, so a space inside quotes is not treated as a new segment. A shell variable used as a path prefix (``$HOME/...``, ``$1/...``) or a named home (``~root/...``) is rejected like ``${HOME}/...`` and ``~/...``, since its - runtime value is unknown here; + runtime value is unknown here, except inside the single-quoted inline + program of awk/gawk/mawk/nawk (outside awk string literals), where bash + expands nothing and ``$NF/2`` or ``($2+$3)/2`` is field arithmetic; that + exemption is withheld from any command that could redefine ``awk`` or + re-read quoted text (see ``_awk_program_field_spans``); ``$PWD/...`` is checked like the relative path it is equivalent to. A windows-incompatible segment still rejects a path that is not already stored on the host; an existing host file may be read or renamed. @@ -1492,9 +1829,20 @@ def validate_local_bash_command_paths(command: str, thread_data: ThreadDataState path_token_occurrences = _absolute_path_token_occurrences(command, tokens) url_spans = _non_file_url_spans(command) + # Inside the single-quoted program of an awk command a ``/`` after ``)`` + # is division (``($2+$3)/2``); only awk string literals, which the spans + # leave out, can carry text to a shell. Only a single-segment match + # (``/2``, ``/n``, ``/NR``) is exempt, so anything shaped like ``/a/b`` + # is still rejected should the scanner ever misjudge a span. + awk_field_spans: list[tuple[int, int]] | None = None for match in _ABSOLUTE_PATH_PATTERN.finditer(command): if _is_in_spans(match.start(), url_spans): continue + if "/" not in match.group()[1:]: + if awk_field_spans is None: + awk_field_spans = _awk_program_field_spans(command) + if _is_in_spans(match.start(), awk_field_spans): + continue absolute_path = match.group() if _is_non_path_literal_fragment(absolute_path): continue @@ -1516,6 +1864,10 @@ def validate_local_bash_command_paths(command: str, thread_data: ThreadDataState for match in _SHELL_VARIABLE_PATH_PATTERN.finditer(command): if _is_in_spans(match.start(), url_spans): continue + if awk_field_spans is None: + awk_field_spans = _awk_program_field_spans(command) + if _is_in_spans(match.start(), awk_field_spans): + continue name, remainder = match.group(1), match.group(2) if name == "PWD" and pwd_is_workspace: continue diff --git a/backend/tests/test_sandbox_tools_security.py b/backend/tests/test_sandbox_tools_security.py index 07e32d16d1..3cebcc1c43 100644 --- a/backend/tests/test_sandbox_tools_security.py +++ b/backend/tests/test_sandbox_tools_security.py @@ -1084,6 +1084,68 @@ def test_validate_local_bash_command_paths_blocks_brace_expansion_host_paths(com ("cat ~root/.ssh/id_rsa", "~root/.ssh/id_rsa"), ("ls ~deploy/", "~deploy/"), ("make --directory=~deploy/app", "~deploy/app"), + # The awk exemption covers only the single-quoted inline program. + ("awk '{print $1}' $HOME/.ssh/id_rsa", "$HOME/.ssh/id_rsa"), + ("awk -f prog.awk '$HOME/.ssh/id_rsa'", "$HOME/.ssh/id_rsa"), + ("awk -v 'cmd=cat $HOME/.ssh/id_rsa' 'BEGIN{system(cmd)}'", "$HOME/.ssh/id_rsa"), + # A string literal inside the program can reach a shell via system(). + ("awk 'BEGIN{system(\"cat $HOME/.ssh/id_rsa\")}'", "$HOME/.ssh/id_rsa"), + # A quote inside an awk regex or comment must not hide a later string. + ('awk \'/"/ {system("cat $HOME/.ssh/id_rsa")}\' /mnt/user-data/workspace/data.txt', "$HOME/.ssh/id_rsa"), + ('awk \'$0 ~ /"/ {system("cat $HOME/.ssh/id_rsa")}\' /mnt/user-data/workspace/data.txt', "$HOME/.ssh/id_rsa"), + ('awk \'{print /"/; system("cat $HOME/.ssh/id_rsa")}\' /mnt/user-data/workspace/data.txt', "$HOME/.ssh/id_rsa"), + ('awk \'{gsub(/"/, ""); system("cat $HOME/.ssh/id_rsa")}\' /mnt/user-data/workspace/data.txt', "$HOME/.ssh/id_rsa"), + ('awk \'{x = "a" # "\nsystem("cat $HOME/.ssh/id_rsa") }\'', "$HOME/.ssh/id_rsa"), + ('awk \'BEGIN { # "\nsystem("cat $HOME/.ssh/id_rsa") }\'', "$HOME/.ssh/id_rsa"), + # In awk a ``/`` after ``)`` or at the start of a line opens a regex. + ('awk \'{ if (1) /"b/; system("cat $HOME/.ssh/id_rsa")}\' /mnt/user-data/workspace/data.txt', "$HOME/.ssh/id_rsa"), + ('awk \'{ while (0) /"b/; system("cat $HOME/.ssh/id_rsa")}\' /mnt/user-data/workspace/data.txt', "$HOME/.ssh/id_rsa"), + ('awk \'NR==1\n/"b/{system("cat $HOME/.ssh/id_rsa")}\' /mnt/user-data/workspace/data.txt', "$HOME/.ssh/id_rsa"), + # An unrecognised option may take the next word as its value (gawk's + # ``--assign`` is ``-v``), so the following word is not the program. + ("gawk --assign 'cmd=cat $HOME/.ssh/id_rsa' 'BEGIN{system(cmd)}'", "$HOME/.ssh/id_rsa"), + ("awk --assign 'cmd=cat $HOME/.ssh/id_rsa' 'BEGIN{system(cmd)}'", "$HOME/.ssh/id_rsa"), + # Re-interpreted or expanded program text is not exempt. + ("sh -c 'awk \"{print \\$HOME/x}\"'", "$HOME/x"), + ("bash -c 'awk {print $HOME/.ssh/id_rsa}'", "$HOME/.ssh/id_rsa"), + ("eval awk '{print $HOME/.ssh/id_rsa}'", "$HOME/.ssh/id_rsa"), + ('awk "{print $HOME/.ssh/id_rsa}"', "$HOME/.ssh/id_rsa"), + ("bash <| awk '{print $NF/2}'", "$NF/2"), + ("echo x) awk '{print $NF/2}'", "$NF/2"), + ("eval $(true) awk '{print $HOME/.ssh/id_rsa}'", "$HOME/.ssh/id_rsa"), + ("echo `true` awk '{print $HOME/.ssh/id_rsa}'", "$HOME/.ssh/id_rsa"), + ('echo "$(echo "x") awk \'{print $HOME/.ssh/id_rsa}\'"', "$HOME/.ssh/id_rsa"), + # Quoting the scanner does not model fails closed. + ("awk $'{print $HOME/.ssh/id_rsa}'", "$HOME/.ssh/id_rsa"), + ("awk '{print $NF/2}' $'/mnt/user-data/workspace/data.txt' 'x' $HOME/.ssh/id_rsa", "$HOME/.ssh/id_rsa"), ], ) def test_validate_local_bash_command_paths_blocks_variable_prefixed_host_paths(command: str, rejected: str) -> None: @@ -1113,6 +1175,38 @@ def test_validate_local_bash_command_paths_blocks_variable_prefixed_host_paths(c # awk field arithmetic is not a path. "du -k /mnt/user-data/workspace | awk '{print $1/1024}'", "awk -F, '{print $1/$2}' /mnt/user-data/uploads/data.csv", + # Inside a single-quoted awk program bash expands nothing and ``$`` + # is awk's field operator (#7021). + "awk '{print $NF/2}' /mnt/user-data/workspace/data.txt", + "awk '{print $sum/$n}' /mnt/user-data/workspace/data.txt", + "awk -F, '{total+=$3} END {print total/NR}' /mnt/user-data/workspace/data.csv", + "gawk '{print $NF/2}' /mnt/user-data/workspace/data.txt", + "mawk '{print $NF/2}' /mnt/user-data/workspace/data.txt", + "nawk '{print $NF/2}' /mnt/user-data/workspace/data.txt", + "awk -F: -v n=2 '{print $NF/n}' /mnt/user-data/workspace/data.txt", + "cat /mnt/user-data/workspace/data.txt | awk -- '{print $NF/2}' && echo done", + "awk '{print \"avg\", $sum/$n}' /mnt/user-data/workspace/data.txt", + "awk '{print $NF/2 \" MB\"}' /mnt/user-data/workspace/data.txt", + # ``/`` and ``#`` inside an awk string literal are plain text. + "awk '{n=split($0,p,\"/\"); print $1/p[n]}' /mnt/user-data/workspace/data.csv", + "awk '{split($0, a, \"/\"); print $1/a[1]}' /mnt/user-data/workspace/data.csv", + "awk -F'#' '{print \"#\" $NF/2}' /mnt/user-data/workspace/data.txt", + # ``/`` after ``)`` is division unless the ``)`` closes an if/while/for + # condition; the absolute-path scan is exempt inside awk code too. + "awk '{avg=($2+$3)/2; print $NF/avg}' /mnt/user-data/workspace/data.txt", + "awk '{print ($NF+1)/2, $NF/2}' /mnt/user-data/workspace/data.txt", + "awk '{print length($0)/2}' /mnt/user-data/workspace/data.txt", + "awk '{a[$1]/=2; print a[$1]/2}' /mnt/user-data/workspace/data.txt", + # The command word is found like the shell does: basename, after an + # assignment prefix, inside ``$(...)``, before a comment. + "/usr/bin/awk '{print $NF/2}' /mnt/user-data/workspace/data.txt", + "LC_ALL=C awk '{print $NF/2}' /mnt/user-data/workspace/data.txt", + "echo \"$(awk '{print $NF/2}' /mnt/user-data/workspace/data.txt)\"", + "x=$(awk '{print $NF/2}' /mnt/user-data/workspace/data.txt); echo \"$x\"", + "awk '{print $NF/2}' /mnt/user-data/workspace/data.txt # don't worry", + "awk '{print \"<<\", $NF/2}' /mnt/user-data/workspace/data.txt", + "(awk '{print $NF/2}' /mnt/user-data/workspace/data.txt)", + "awk '{print $NF/2}' /mnt/user-data/workspace/data.txt 2>/dev/null", ], ) def test_validate_local_bash_command_paths_allows_non_path_variable_uses(command: str) -> None: @@ -1134,6 +1228,11 @@ def test_validate_local_bash_command_paths_rejects_cdpath_relocation(command: st validate_local_bash_command_paths(command, _THREAD_DATA) +def test_validate_local_bash_command_paths_rejects_absolute_path_after_awk_program() -> None: + with pytest.raises(PermissionError, match="/etc/passwd"): + validate_local_bash_command_paths("awk '{print $NF/2}' /etc/passwd", _THREAD_DATA) + + def test_validate_local_bash_command_paths_rejects_pwd_traversal_like_relative_traversal() -> None: for command in ("cat ../../etc/passwd", "cat $PWD/../../etc/passwd"): with pytest.raises(PermissionError, match="path traversal"):