fix(sandbox): exempt awk field arithmetic from the shell-variable path guard (#7023)

* fix(sandbox): exempt awk field arithmetic from the shell-variable path guard

Since #6930 every `$NAME/` in a host-bash command was treated as a
variable-prefixed host path unless both sides were digits, so legitimate
awk programs such as `awk '{print $NF/2}' data.txt` or
`awk '{print $sum/$n}' data.txt` on files under /mnt/user-data were
rejected with "Unsafe absolute paths".

Exempt a `$` only inside the single-quoted inline program of awk, gawk,
mawk or nawk (after `-F`, `-v` and `--`), where bash expands nothing and
awk reads `$NAME` as a field reference. awk string literals, option
values, input operands, double-quoted programs, text re-read by `sh -c`
or `eval`, heredocs, awk functions/aliases, unknown options such as
gawk's `--assign`, `-f` programs, and programs whose `"` may sit in a
regex or comment keep the strict scan.

Fixes bytedance/deer-flow#7021

* fix(sandbox): read awk string literals and comments before judging quotes

Review follow-up: the quote-reliability check gave up the awk exemption
for any `/` not preceded by an identifier, number or `]`, including a `/`
inside a plain string literal, so `awk '{n=split($0,p,"/"); print
$1/p[n]}'` was still rejected. Walk the program once with the same
string-literal tracker that computes the code spans: `/` and `#` inside
a literal are text, `#` outside one starts a comment to end of line, and
only a `/` outside a literal that cannot be division (regex literal)
forfeits the exemption.

* fix(sandbox): scan the awk exemption the way the shell parses the command

Review follow-up for the awk field-arithmetic exemption.

Bypasses closed: a backslash-newline let `aw\<nl>k() { eval "$1"; }`
define an awk function past the literal blocker regex, so any
continuation now forfeits the exemption; the word scanner emitted `)`,
`$(` and `>|` as command separators, so an `awk` that was only an
argument (`echo $(true) awk '...'`) looked like a command; `$'...'`,
comments and quotes nested in `"$(...)"` could desynchronise quote
pairing; the alias/function blocker only matched literal spellings and
ignored eval, source, hash, PATH, autoload and zsh's
`functions[awk]=` / `aliases[awk]=` arrays.

The scanner now follows bash: single and double quotes with escapes,
`#` comments at a word start, fd redirections, the usual separators,
and `$(...)` as a nested command context with fresh quoting. Anything
it does not model (backslash-newline, backticks, `$'...'`, heredocs, an
unmatched `)`, unbalanced quotes) returns no exemption. The command
word is found like `_validate_local_bash_shell_tokens` finds it:
after assignments, prefix keywords and wrappers, reduced to its
basename, inside every `$(...)`. A command that could redefine awk or
re-read quoted text gets no exemption at all, and the docstring says
the enumeration is best-effort.

False positives removed: `($2+$3)/2` and `length($0)/2` (a `/` after
`)` is division unless the `)` closes an if/while/for condition, and
the absolute-path scan is now exempt inside awk code too),
`/usr/bin/awk`, `LC_ALL=C awk`, `echo "$(awk ...)"`, `"<<"` inside an
awk string. The awk spans are computed lazily, only when a `$NAME/` or
absolute-path match needs them, and the division check scans backward
instead of re-slicing the program.

* fix(sandbox): exempt only single-segment absolute-path matches in awk code

Review follow-up. Inside awk code spans the absolute-path scan now skips
only a match with no second `/` (`/2`, `/n`, `/NR`), so a `/a/b` shape
is still rejected even if a span were ever misjudged; previously
`awk '{print ($1)/etc/passwd}'` and `awk 'BEGIN{x = 1 /etc/shadow}'`
were accepted.

`enable` joins the commands that withhold the exemption, since
`enable -f` can load a builtin under any name. The division check reads
the preceding `)` through the backward scan instead of re-slicing the
program for every `/`.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
AochenShen99 2026-10-19 15:26:57 +08:00 committed by GitHub
parent b6ce047510
commit 8a1acf8009
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 472 additions and 1 deletions

View File

@ -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

View File

@ -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"(?<![:\w])(?<!:/)/(?:[^\s\"'`;&|<>()]+)")
# 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

View File

@ -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 <<EOF\nawk '{print $HOME/.ssh/id_rsa}'\nEOF", "$HOME/.ssh/id_rsa"),
("awk() { bash -c \"$1\"; }; awk 'cat $HOME/.ssh/id_rsa'", "$HOME/.ssh/id_rsa"),
# bash splices a backslash-newline before parsing, so these define an
# ``awk`` function or alias too; a continuation anywhere forfeits the exemption.
('aw\\\nk() { eval "$1"; }; awk \'printf "%s" $HOME/.ssh/id_rsa\'', "$HOME/.ssh/id_rsa"),
('function aw\\\nk() { eval "$1"; }; awk \'printf "%s" $HOME/.ssh/id_rsa\'', "$HOME/.ssh/id_rsa"),
("alia\\\ns awk=eval; shopt -s expand_aliases; awk 'printf \"%s\" $HOME/.ssh/id_rsa'", "$HOME/.ssh/id_rsa"),
# Commands that can redefine what ``awk`` runs forfeit the exemption.
("awk () { eval \"$1\"; }; awk 'cat $HOME/.ssh/id_rsa'", "$HOME/.ssh/id_rsa"),
("function awk { eval \"$1\"; }; awk 'cat $HOME/.ssh/id_rsa'", "$HOME/.ssh/id_rsa"),
("hash -p /mnt/user-data/workspace/evil awk; awk 'cat $HOME/.ssh/id_rsa'", "$HOME/.ssh/id_rsa"),
("PATH=/mnt/user-data/workspace:$PATH awk 'cat $HOME/.ssh/id_rsa'", "$HOME/.ssh/id_rsa"),
("export PATH=/mnt/user-data/workspace:$PATH; awk 'cat $HOME/.ssh/id_rsa'", "$HOME/.ssh/id_rsa"),
("source /mnt/user-data/workspace/defs.sh; awk 'cat $HOME/.ssh/id_rsa'", "$HOME/.ssh/id_rsa"),
(". /mnt/user-data/workspace/defs.sh; awk 'cat $HOME/.ssh/id_rsa'", "$HOME/.ssh/id_rsa"),
("eval \"$(cat /mnt/user-data/workspace/defs.sh)\"; awk 'cat $HOME/.ssh/id_rsa'", "$HOME/.ssh/id_rsa"),
# zsh defines functions and aliases through special arrays.
("functions[awk]='eval \"$1\"'; awk 'cat $HOME/.ssh/id_rsa'", "$HOME/.ssh/id_rsa"),
("functions[awk]='eval \"$1\"'; awk 'cat /etc/passwd'", "/etc/passwd"),
("aliases[awk]=eval; awk 'cat $HOME/.ssh/id_rsa'", "$HOME/.ssh/id_rsa"),
("autoload -Uz awk; awk 'cat $HOME/.ssh/id_rsa'", "$HOME/.ssh/id_rsa"),
("enable -f /mnt/user-data/workspace/evil.so awk; awk 'cat $HOME/.ssh/id_rsa'", "$HOME/.ssh/id_rsa"),
# Inside awk code only a single-segment ``/x`` skips the absolute-path
# scan, so a multi-segment path stays rejected even after ``)``.
("awk '{print ($1)/etc/passwd}' /mnt/user-data/workspace/data.txt", "/etc/passwd"),
("awk 'BEGIN{x = 1 /etc/shadow}'", "/etc/shadow"),
# An ``awk`` word that is an argument of another command is not a command.
("echo $(true) awk '{print $NF/2}'", "$NF/2"),
("echo >| 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"):