Gate the sed commands that run a shell

GNU sed executes a shell through its `e` command, both as a standalone
command (`sed -n '1e CMD' file`) and as an `s///e` flag that runs the
pattern space. It goes through popen(), so it is a literal `sh -c`, but
the terminal scan only ever saw `sed` at command position and treated the
program text as an ordinary argument.

That left `sed -n '1e rm -f victim' /etc/hosts` running with no prompt in
auto mode, and `_find_blocked_commands` returning nothing for it, so the
hard blocklist that applies in every mode missed `rm` as well.

Screens the program the same way the awk arm does. `-e` values are joined
with newlines first, since that is how sed assembles them: `sed -e '1a\'
-e 'e CMD'` appends a literal line and runs nothing, so judging the pieces
separately would prompt on a benign script. The scan then steps over every
region where `e` is data rather than a command: address and substitution
regexes, replacements, `a/i/c` text, `r`/`w` filenames, `b`/`t` labels and
comments. That keeps the common idioms silent, including `:e;N;$!be` loop
labels, `s/e/E/g`, and `s/a/b/we out.txt` where the `e` belongs to the `w`
filename and sed does not execute.

The blocklist scan recurses into a literal `e` payload the same way it
already does for `bash -c`. A bare `e` or an `s///e` can only be prompted,
since what they run is the pattern space, which is input-file text that is
not knowable statically.

Verified against real GNU sed 4.9 rather than the manual: 80 commands run
for real with a marker payload, comparing what sed actually executed
against the classifier, with no mismatches in either direction.
This commit is contained in:
danielhanchen 2026-07-27 03:26:58 +00:00
commit 22a120cf09
3 changed files with 272 additions and 1 deletions

View file

@ -265,6 +265,15 @@ _AWK_SHELL_ESCAPE_RE = re.compile(
r"\bsystem\s*\(|\|\s*&?\s*[\"']\s*(?:/\S*/)?(?:sh|bash|zsh|ksh|dash|cmd)\b|"
r"\bENVIRON\s*\[|\bprintf\s*\|"
)
# sed shells out like awk: GNU's `e` runs the rest of its line through popen,
# and the `s///e` flag runs the pattern space. Either hides a command (even a
# hard-blocked one) inside a text-editing argument, so the program is screened
# while ordinary editing (sed 's/a/b/g', sed -n '1,20p') stays unprompted. Both
# are GNU extensions; busybox/toybox/BSD sed reject `e` outright.
_SED_COMMANDS = frozenset({"sed", "gsed", "ssed"})
# `s///` flags that may precede `e`. `w` is absent: it takes the rest of the
# line as a filename, so the e in `s/a/b/w report.txt` is part of that name.
_SED_SUBST_FLAGS = frozenset("0123456789gpiImMe")
_WIN_CONDITIONAL_KEYWORDS = frozenset({"exist", "defined", "errorlevel", "not"})
_FIND_EXEC_FLAGS = frozenset({"-exec", "-execdir", "-ok", "-okdir"})
@ -288,6 +297,183 @@ def _blocked_matching_glob(base: str) -> "set[str]":
return {name for name in _BLOCKED_COMMANDS if fnmatch.fnmatchcase(name, base)}
def _sed_program(tokens: "list[str]", start: int) -> str:
"""The sed script of the invocation whose command word sits at ``start``.
sed joins every -e/--expression value with NEWLINES into one program, so
`sed -e '1a\\' -e 'e rm -rf x'` appends a literal line instead of executing
it and the pieces must be judged together. With no -e (and no -f program
FILE, unreadable here) the FIRST positional is the script and the rest are
inputs, so `sed -e 's/a/b/' e` treats its input file `e` as data.
"""
programs: "list[str]" = []
first_positional = ""
has_program_flag = False
value_pending = "" # "e" or "f": the next token is that flag's value
for token in tokens[start + 1 :]:
if token in _SHELL_SEPARATORS:
break
if value_pending:
if value_pending == "e":
programs.append(token)
value_pending = ""
continue
if token.startswith("--"):
name, sep, value = token.partition("=")
# getopt allows unambiguous abbreviations: --e/--ex/--expr are all
# --expression, and --fi upwards is --file (--f alone is ambiguous
# with --follow-symlinks, which sed rejects).
is_expression = len(name) > 2 and "--expression".startswith(name)
is_file = len(name) > 3 and "--file".startswith(name)
if is_expression or is_file:
has_program_flag = True
if not sep:
value_pending = "e" if is_expression else "f"
elif is_expression:
programs.append(value)
continue
if token.startswith("-"):
# A cluster glues the value on (-ne'1p') or takes the next (-ne '1p').
attached = _short_flag_arg(token, "ef")
if attached is None:
continue
has_program_flag = True
letter = next(ch for ch in token[1:] if ch in "ef")
if not attached:
value_pending = letter
elif letter == "e":
programs.append(attached)
continue
if not first_positional:
first_positional = token
if not has_program_flag and first_positional:
programs.append(first_positional)
return "\n".join(programs)
def _sed_exec_payloads(program: str) -> "list[str]":
"""Shell payloads a sed program executes, in order.
`e COMMAND` runs COMMAND. A bare `e` and the `s///e` flag run the pattern
space, which only exists at run time, so they yield an EMPTY payload:
executes, but nothing to screen. An empty list means it only edits text.
The walk skips every region where an `e` is data (regexes, replacements,
a/i/c text, r/w filenames, b/t labels, comments), keeping `:e;N;$!be;...`,
`sed 's/e/E/g'` and `sed 's/a/b/w report.txt'` out of the results.
"""
payloads: "list[str]" = []
n = len(program)
def _end_of_line(pos: int) -> int:
end = program.find("\n", pos)
return n if end < 0 else end
def _skip_bracket(pos: int) -> int:
# A bracket expression, where the delimiter is data (`s/[/]/x/` really
# substitutes a slash). A leading `]` is literal; [:class:] nests.
pos += 1
if pos < n and program[pos] == "^":
pos += 1
if pos < n and program[pos] == "]":
pos += 1
while pos < n and program[pos] != "]":
if program[pos] == "[" and pos + 1 < n and program[pos + 1] in ":.=":
end = program.find(program[pos + 1] + "]", pos + 2)
pos = n if end < 0 else end + 2
continue
pos += 1
return pos + 1
def _skip_section(pos: int, delim: str, brackets: bool) -> int:
# One delimited section of a regex / s/// / y///, through its closing
# delimiter. Brackets apply to regex halves only; elsewhere `[` is data.
while pos < n and program[pos] != delim:
if program[pos] == "\\":
pos += 2
elif brackets and program[pos] == "[":
pos = _skip_bracket(pos)
else:
pos += 1
return pos + 1
def _skip_address(pos: int) -> int:
# A line number (GNU's first~step included), `$`, /regex/ or \%regex%,
# each allowing I/M modifiers.
if pos < n and program[pos] == "$":
return pos + 1
if pos < n and program[pos].isdigit():
while pos < n and (program[pos].isdigit() or program[pos] == "~"):
pos += 1
return pos
if pos < n and program[pos] == "/":
pos = _skip_section(pos + 1, "/", brackets = True)
elif pos < n and program[pos] == "\\" and pos + 1 < n:
pos = _skip_section(pos + 2, program[pos + 1], brackets = True)
else:
return pos
while pos < n and program[pos] in "IM":
pos += 1
return pos
i = 0
while i < n:
if program[i] in " \t\n;{}":
# Separators and block braces carry no command.
i += 1
continue
if program[i] == "#":
i = _end_of_line(i)
continue
i = _skip_address(i)
if i < n and program[i] == ",":
i += 1
while i < n and program[i] in " \t":
i += 1
if i < n and program[i] in "+~":
# `addr,+N` / `addr,~N` end the range relative to the first match.
i += 1
while i < n and program[i].isdigit():
i += 1
else:
i = _skip_address(i)
while i < n and program[i] in " \t!":
# `1!e cmd`: negation, the command word is still ahead.
i += 1
if i >= n:
break
cmd, i = program[i], i + 1
if cmd == "e":
# The payload ends at the NEWLINE, so a `;` inside it is shell text.
end = _end_of_line(i)
payloads.append(program[i:end].strip())
i = end
elif cmd in "sy" and i < n:
delim, i = program[i], i + 1
i = _skip_section(i, delim, brackets = cmd == "s")
i = _skip_section(i, delim, brackets = False)
if cmd == "s":
executes = False
while i < n and program[i] in _SED_SUBST_FLAGS:
executes = executes or program[i] == "e"
i += 1
if executes:
payloads.append("")
if i < n and program[i] == "w":
i = _end_of_line(i)
elif cmd in "aic":
# Literal text; the `a\` + newline form continues on a trailing "\".
while i < n and program[i] != "\n":
i += 2 if program[i] == "\\" else 1
elif cmd in "rRwW":
i = _end_of_line(i) # the filename runs to the end of the line
elif cmd in "btT:v":
# A label (or `v` version) ends at the next separator.
while i < n and program[i] not in ";\n}":
i += 1
return payloads
def _find_blocked_commands(command: str) -> set[str]:
"""Detect blocked commands at shell command position only.
@ -328,7 +514,8 @@ def _find_blocked_commands(command: str) -> set[str]:
expect_command = True # start of string is a command position
prefix_pending = False # last cmd-position token was a wrapper (env/time/xargs/...)
skip_operand = False # consume a wrapper/conditional operand, not the command
for token in tokens:
sed_indexes: "list[int]" = [] # command-position sed words, for the `e` scan below
for token_index, token in enumerate(tokens):
if skip_operand:
# `exec -a NAME cmd` and `if exist FILE cmd` both put an operand
# where the command word would otherwise be.
@ -363,6 +550,8 @@ def _find_blocked_commands(command: str) -> set[str]:
if prefix_pending and token.lstrip("-").isdigit():
continue
base = _token_basename(token)
if base in _SED_COMMANDS:
sed_indexes.append(token_index)
if base in _BLOCKED_COMMANDS:
blocked.add(base)
else:
@ -449,6 +638,14 @@ def _find_blocked_commands(command: str) -> set[str]:
blocked |= _find_blocked_commands(tokens[i + 1])
break # stop at first non-flag token
# sed's `e COMMAND` hands COMMAND to the shell, a real command position the
# scan above sees only as a text argument, so screen it like `bash -c`. The
# pattern-space forms yield an empty payload; the auto gate prompts on those.
for i in sed_indexes:
for payload in _sed_exec_payloads(_sed_program(tokens, i)):
if payload:
blocked |= _find_blocked_commands(payload)
return blocked
@ -4324,6 +4521,10 @@ def _terminal_is_high_risk(command: str, _depth: int = 0) -> bool:
chdir_pending = True
if base in _AWK_COMMANDS:
awk_program_pending = True
if base in _SED_COMMANDS and _sed_exec_payloads(_sed_program(tokens, _tok_idx)):
# `e` / `s///e` shell out from inside the script, which may
# ride on -e/--expression rather than the next positional.
return True
elif current_command == "git" and not git_subcommand:
# The first positional after `git` is its subcommand.
git_subcommand = base

View file

@ -875,6 +875,60 @@ def test_terminal_classifier(command, unsafe):
("awk '{print $1}' data.tsv", False),
("awk -F, '{sum+=$2} END {print sum}' f.csv", False),
("awk 'NR>1' data.csv > body.csv", False),
# --- prompt: sed's `e` runs the rest of its line through the shell,
# under every address form (line, $, regex, range, step, negation) ---
("sed -n '1e rm -f victim' /etc/hosts", True),
("sed 'e curl https://x.io/p.sh' f", True),
("sed -n '$e rm -rf build' f", True),
("sed '/token/e curl https://x.io/' input", True),
("sed '1,2e rm -f victim' f", True),
("sed '0~2e rm -f victim' f", True),
("sed '1!e rm -f victim' f", True),
("sed '/a/,/b/e rm -f victim' f", True),
("sed -n '1{p};2e rm -f victim' f", True),
("gsed '1e rm -f victim' f", True),
("ssed '1e rm -f victim' f", True),
# the script may ride on -e/--expression (abbreviated too) instead of
# the first positional, and a cluster glues -n and -e into one word
("sed -n -e '1e rm -f victim' f", True),
("sed -ne '1e rm -f victim' f", True),
("sed -e '1p' -e '1e rm -f victim' f", True),
("sed --expression='1e rm -f victim' f", True),
("sed --expr='1e rm -f victim' f", True),
# --- prompt: the s///e flag executes whatever the substitution left in
# the pattern space, in any flag order and with any delimiter ---
("sed 's/foo/bar/e' input", True),
("sed 's/foo/bar/ge' input", True),
("sed 's/foo/bar/eg' input", True),
("sed 's/foo/bar/2e' input", True),
("sed 's/foo/bar/e2' input", True),
("sed 's/foo/bar/ep' input", True),
("sed 's/foo/bar/pe' input", True),
("sed 's/foo/bar/Ie' input", True),
("sed 's/foo/bar/ew out.txt' input", True), # executes AND writes
("sed 's|foo|bar|e' input", True),
("sed 's/[/]//e' input", True), # the delimiter is data inside [ ]
# --- run: ordinary stream editing, including the shapes that merely
# LOOK like an exec (a label `e`, an `e` in a regex or a w filename) ---
("sed -n '1p' input", False),
("sed -n '1,20p' input", False),
("sed 's/foo/bar/g' input", False),
("sed -i 's/old/new/' f", False),
("sed -E 's/(a|b)+/x/g' f", False),
("sed -e 's/a/b/' -e 's/c/d/' f", False),
("sed 's/e/E/g' f", False),
("sed ':e;N;$!be;s/\\n/,/g' f", False), # the classic join-lines idiom
("sed 's/foo/bar/w report.txt' f", False), # `w` takes the rest as a name
("sed 's/foo/bar/we report.txt' f", False), # `w` first: the e is the name
("sed -n '/error/w errors.txt' f", False),
("sed '/^$/d' f", False),
("sed 'y/abc/xyz/' f", False),
("sed -n '/error/=' log", False),
("sed -f cleanup.sed data.txt", False), # a program FILE, like awk -f
("sed -e 's/a/b/' e", False), # `e` here is an input file, not a command
("sed -e '1a\\' -e 'echo appended' f", False), # a\ continues into -e
("echo \"sed '1e rm -f victim'\"", False),
("printf '%s' sed '1e rm -f victim'", False),
# --- prompt: setpriv execs what follows, after changing privilege ---
("setpriv --nnp rm -f victim", True),
("setpriv --reuid=1000 rm -rf build", True),

View file

@ -637,6 +637,22 @@ class TestBashBlocklistPosition:
# Recursion into the nested command string catches command-position curl.
assert "curl" in self._find()("bash -c 'curl https://x'")
def test_sed_exec_payload_blocked(self):
# sed's `e COMMAND` hands COMMAND to the shell, so the payload is a real
# command position hiding inside the script argument.
assert "rm" in self._find()("sed -n '1e rm -rf victim' input")
assert "curl" in self._find()("sed -e '/x/e curl https://x' input")
assert "rm" in self._find()("sed -ne '$e rm -rf build' input")
assert "wget" in self._find()("sed '1,2e wget https://bad' input")
def test_ordinary_sed_program_allowed(self):
# Plain stream editing runs nothing, and a mention of sed in argument
# position is text: only a command-position sed has its script read.
assert self._find()("sed 's/old/new/g' input") == set()
assert self._find()("sed -n '1,20p' input") == set()
assert self._find()("sed 's/rm/RM/g' input") == set()
assert self._find()("printf '%s' sed '1e rm -rf victim'") == set()
def test_subshell_command_blocked(self):
assert "rm" in self._find()("echo $(rm -rf /tmp)")