fix(security): anchor rm hardline rules to command position (#56193)

A literal "rm -rf /" carried as DATA inside another command's quoted
argument — a PR title, a git commit -m message, an echo/printf arg —
tripped the unconditional root-filesystem hardline and could not run at
all. `gh pr create --title "block rm -rf / spellings"` was blocked
outright, because the bare rm path branch matched the mid-string "rm"
(via \brm) with the space after "/" satisfying its (\s|$) terminator.

Anchor the shared _RM_FLAG_PREFIX to _CMDPOS so the rm hardline rules
fire only when rm is an actual command word (start of line, after a
separator ; && || |, after a subshell opener $()/backtick, or after
sudo/env/exec wrappers) — not when the string appears as an argument
value. Broaden the bare-path terminator to also accept shell
metacharacters ) ` ; | & so a real wipe inside a command substitution
is still caught.

The quoted-path branch is unchanged, so quoted root/HOME paths stay
blocked. Adds regression tests for both directions: data-arg false
positives must NOT block, real wipes at every command position must block.
This commit is contained in:
Teknium 2026-07-01 01:54:43 -07:00 committed by GitHub
parent 6e97f5c3f8
commit 7534b5be2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 65 additions and 7 deletions

View file

@ -200,6 +200,54 @@ def test_quoted_and_brace_paths_are_hardline_blocked(command):
assert desc
# Commands that carry the literal string "rm -rf /" (or a sibling) as DATA in
# another command's quoted argument — a PR title, a commit message, an echo /
# printf argument. The shell never executes that text as an rm command, so the
# hardline floor must NOT fire; otherwise the command cannot run at all (this
# blocked `gh pr create --title "…rm -rf /…"` outright). Regression guard for
# the command-position anchor on the rm rules.
_DATA_ARG_NOT_A_COMMAND = [
'gh pr create --title "block rm -rf / spellings"',
'git commit -m "fixes rm -rf / bypass"',
'echo "run rm -rf / now"',
'echo "rm -rf /"',
'printf "%s" "rm -rf /"',
'gh issue comment 1 --body "the fix blocks rm -rf //"',
]
@pytest.mark.parametrize("command", _DATA_ARG_NOT_A_COMMAND)
def test_root_wipe_string_as_data_arg_is_not_hardline(command):
""""rm -rf /" as a quoted argument to another command is data, not a wipe."""
is_hl, desc = detect_hardline_command(command)
assert not is_hl, f"false positive: quoted data arg hit hardline floor: {command!r} ({desc})"
# Real root wipes at every command position — bare, chained after a separator,
# inside a command substitution ($()/backtick), or after sudo/env wrappers.
# The command-position anchor must keep catching all of these; the substitution
# forms exercise the shell-metacharacter terminator on the bare path branch.
_COMMAND_POSITION_ROOT_WIPES = [
"rm -rf /",
"ls && rm -rf /",
"ls; rm -rf /",
"echo x | rm -rf /",
"sudo rm -rf /",
"env X=1 rm -rf /",
"$(rm -rf /)",
"`rm -rf /`",
'echo "$(rm -rf /)"',
]
@pytest.mark.parametrize("command", _COMMAND_POSITION_ROOT_WIPES)
def test_root_wipe_at_command_position_is_hardline(command):
"""A real `rm -rf /` at any command position stays hardline-blocked."""
is_hl, desc = detect_hardline_command(command)
assert is_hl, f"real root wipe leaked past the floor: {command!r}"
assert desc
# -------------------------------------------------------------------------
# Shell line-continuation bypass
# -------------------------------------------------------------------------

View file

@ -332,12 +332,12 @@ _CMDPOS = (
# `rm -rf "/"` slip past the unconditional floor entirely.
#
# Accept the path either fully wrapped in a matching quote pair OR bare with
# a whitespace/end terminator. The matching-quote requirement is deliberate:
# it catches `rm -rf "/"` (path quoted on its own) while NOT firing on a
# dangerous-looking string that is merely an argument to another command —
# e.g. `git commit -m "rm -rf /"` — where the closing quote follows the path
# but no opening quote precedes it, so neither branch applies.
def _hardline_rm_path(path_alt: str, tail: str = r'(?:\s|$)') -> str:
# a terminator. The matching-quote branch catches `rm -rf "/"` (path quoted
# on its own). The bare branch's terminator accepts whitespace, end-of-string
# OR a shell metacharacter (`) ` ; | &`) so a real root wipe inside a command
# substitution — `$(rm -rf /)`, `` `rm -rf /` `` — whose `/` is terminated by
# `)`/backtick is still caught.
def _hardline_rm_path(path_alt: str, tail: str = r'(?:\s|$|[)`;|&])') -> str:
return rf'(?:["\'](?:{path_alt})["\']|(?:{path_alt}){tail})'
@ -350,7 +350,17 @@ _HARDLINE_SYSTEM_DIRS = (
# `rm` plus its flag group, shared by the three rm hardline rules. Kept as a
# plain concatenation (not an f-string) so the regex backslashes never live
# inside an f-string replacement field — unsupported on the Python 3.11 floor.
_RM_FLAG_PREFIX = r'\brm\s+(-[^\s]*\s+)*'
#
# Anchored to _CMDPOS (start of line, after a command separator ; && || |,
# after a subshell opener $(/backtick, or after sudo/env/exec wrappers) so the
# rule fires only when `rm` is an actual command word — not when the literal
# string "rm -rf /" appears as DATA inside another command's argument, e.g.
# `gh pr create --title "block rm -rf / spellings"` or `git commit -m "…rm -rf
# /…"`. Those tripped the unconditional floor and could not run at all before
# the anchor. A real wipe at any command position (bare, chained, in $()/`…`,
# under sudo) still matches; the quoted-path branch in _hardline_rm_path keeps
# catching `rm -rf "/"`.
_RM_FLAG_PREFIX = _CMDPOS + r'rm\s+(-[^\s]*\s+)*'
HARDLINE_PATTERNS = [
# rm recursive targeting the root filesystem or protected roots.