core/tools: read each wrapper's options with getopt

Four hand-written tables said which wrapper options take a value, and the
comment above them admitted the failure mode: an entry for a flag eats the
program behind it, which is how `exec -c rm -rf /` hid `rm`. Guessing was the
other half — an option nobody listed was skipped bare, so the token after it
became the program.

Each wrapper now carries a `getopt` spec instead (`command_scan._WRAPPERS`:
short options with `:`, long options with `=`), which spells flags and values
apart, understands jammed forms (`-p2222`, `--with=pkg`), `--`, and unique long
prefixes, and stops at the first token that is not an option — exactly the
wrapped program. The remote clients and the `run` launchers are the same thing
with a flag on the spec.

An option the spec does not know is no longer skipped as if it were a flag: the
unwrap stops at that wrapper (`unparsed`) and the review asks — `options not
understood sudo (cannot read its options: option --new-sudo-flag not
recognized)`, with the whole argv. The wrapper itself stays the command the
denylist judges, so a new `sudo` flag cannot hide `rm` from the hard gate.
This commit is contained in:
2026-10-23 10:47:36 +08:00
parent 3cb4d490ed
commit 12236cffb5
2 changed files with 287 additions and 266 deletions
+285 -266
View File
@@ -9,6 +9,7 @@ program — further in.
from __future__ import annotations from __future__ import annotations
import getopt
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -28,34 +29,9 @@ __all__ = [
"unwrap_command", "unwrap_command",
] ]
# Wrappers that run the program named on their own command line.
_ENV_WRAPPERS: frozenset[str] = frozenset(
{
"command",
"doas",
"env",
"exec",
"ionice",
"nice",
"nohup",
"pkexec",
"setsid",
"stdbuf",
"su",
"sudo",
"time",
"timeout",
"watch",
"xargs",
}
)
# Detached runners: review-worthy whatever program they wrap. # Detached runners: review-worthy whatever program they wrap.
_SELF_REVIEW: frozenset[str] = frozenset({"nohup"}) _SELF_REVIEW: frozenset[str] = frozenset({"nohup"})
# Wrappers that take a bare duration of their own (``timeout 5 cmd``).
_DURATION_WRAPPERS: frozenset[str] = frozenset({"timeout"})
# ``<launcher> run <program> …``: task runners and package managers that launch # ``<launcher> run <program> …``: task runners and package managers that launch
# a program in their environment. A package-manager script (``npm run build``) # a program in their environment. A package-manager script (``npm run build``)
# names a script rather than a program; the launcher basename is still checked. # names a script rather than a program; the launcher basename is still checked.
@@ -119,99 +95,185 @@ _ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=")
# A bare duration, as ``timeout`` takes: ``5``, ``1.5``, ``30s``. # A bare duration, as ``timeout`` takes: ``5``, ``1.5``, ``30s``.
_DURATION = re.compile(r"[0-9]+(?:\.[0-9]+)?[smhd]?") _DURATION = re.compile(r"[0-9]+(?:\.[0-9]+)?[smhd]?")
# Options that consume the next token, so ``sudo -u root python`` still finds # Every wrapper's own options, spelled the way ``getopt`` reads them: a short
# ``python``; jammed forms (``-n10``, ``--with=pkg``) need no entry, and unknown # option with ``:`` (``u:``) takes a value, a bare one is a flag, and a long
# options are skipped bare. List value-taking options only: an entry for a flag # option ending in ``=`` takes one. The flags matter as much as the values — a
# eats the program behind it, which hid ``rm`` from ``exec -c rm -rf /`` and # value-only list let a flag eat the program behind it (``exec -c rm -rf /``
# judged ``exec -c git -c … init`` as ``init``. An option with an *optional* # resolved to the ``-c``, hiding ``rm``), and an unknown option is no longer
# value (``xargs -e``, ``watch -d``) needs no entry either — its bare form runs # guessed at (see `_parse_options`).
# the program, and the jammed form carries the value in the same token.
_VALUE_OPTS: dict[str, frozenset[str]] = {
"doas": frozenset({"-u", "--user"}), @dataclass(frozen=True)
"env": frozenset({"-C", "-S", "-u", "--chdir", "--split-string", "--unset"}), class _Spec:
"exec": frozenset({"-a"}), """How one wrapper is invoked, so the program behind it can be found."""
"ionice": frozenset({"-c", "-n", "-p", "-P", "-u"}),
"nice": frozenset({"-n", "--adjustment"}), short: str = ""
"pkexec": frozenset({"--user"}), long: tuple[str, ...] = ()
"stdbuf": frozenset({"-e", "-i", "-o"}), #: Options whose value is a command line the wrapper runs (``env -S``).
"su": frozenset({"-c", "-g", "-G", "-s", "-u", "-w", "--command", "--group", "--session-command", "--shell"}), command_opts: frozenset[str] = frozenset()
"sudo": frozenset( #: Takes a bare duration of its own before its command (``timeout 5 cmd``).
{ duration: bool = False
"-C", #: ``<launcher> run <program>``: only the ``run`` form wraps a program.
"-D", launcher: bool = False
"-g", #: Runs its command on another machine (``ssh`` / ``mosh``).
"-h", remote: bool = False
"-p",
"-r",
"-R", _WRAPPERS: dict[str, _Spec] = {
"-t", "command": _Spec(short="pvV"),
"-T", "doas": _Spec(short="C:nsu:", long=("user=",)),
"-u", # ``env -S`` / ``su -c`` carry a *command line*: the value is code the review
"-U", # must surface (`_Spec.command_opts`), not an argument.
"--close-from", "env": _Spec(
"--command-timeout", short="0iC:u:S:v",
"--group", long=("chdir=", "debug", "ignore-environment", "null", "split-string=", "unset="),
"--host", command_opts=frozenset({"--split-string", "-S"}),
"--other-user", ),
"--prompt", "exec": _Spec(short="cla:"),
"--role", "ionice": _Spec(short="c:n:p:P:u:thV"),
"--type", "nice": _Spec(short="n:", long=("adjustment=",)),
"--user", "nohup": _Spec(),
} "pkexec": _Spec(long=("disable-internal-agent", "help", "user=", "version")),
"setsid": _Spec(short="cfwV"),
"stdbuf": _Spec(short="e:i:o:", long=("error=", "input=", "output=")),
"su": _Spec(
short="c:g:G:lmps:u:w:",
long=(
"command=",
"group=",
"login",
"preserve-environment",
"session-command=",
"shell=",
"whitelist-environment=",
),
command_opts=frozenset({"--command", "--session-command", "-c"}),
),
"sudo": _Spec(
short="AbEHiKknPSsVvLlC:D:g:h:p:r:R:t:T:u:U:",
long=(
"askpass",
"background",
"chdir=",
"close-from=",
"command-timeout=",
"edit",
"group=",
"host=",
"list",
"login",
"non-interactive",
"other-user=",
"preserve-env",
"prompt=",
"remove-timestamp",
"reset-timestamp",
"role=",
"set-home",
"shell",
"stdin",
"type=",
"user=",
"validate",
"version",
),
),
"time": _Spec(short="af:o:pq", long=("append", "format=", "output=", "portability", "quiet", "verbose")),
"timeout": _Spec(
short="k:s:v",
long=("foreground", "kill-after=", "preserve-status", "signal="),
duration=True,
),
"watch": _Spec(
short="bcdeghn:prtvx",
long=(
"beep",
"chgexit",
"color",
"differences",
"errexit",
"exec",
"interval=",
"no-rerun",
"no-title",
"precise",
),
),
"xargs": _Spec(
short="0a:d:eE:iI:L:ln:P:prs:txo",
long=(
"arg-file=",
"delimiter=",
"eof=",
"interactive",
"max-args=",
"max-chars=",
"max-lines=",
"max-procs=",
"no-run-if-empty",
"null",
"open-tty",
"replace=",
"show-limits",
"verbose",
),
), ),
"time": frozenset({"-f", "-o", "--format", "--output"}),
"timeout": frozenset({"-k", "-s", "--kill-after", "--signal"}),
"watch": frozenset({"-n"}),
"xargs": frozenset({"-a", "-d", "-E", "-I", "-L", "-n", "-P", "-s"}),
} }
# Options whose value is a *command line* the wrapper will run, so the value is # ``<launcher> run <program> …``: the option sets of the launchers below differ,
# code rather than an argument: ``env -S "rm -rf /"`` and ``su -c "…"``. An # so one union stands in for all of them — a value option is the only kind that
# interpreter's own ``-c`` is the same idea, but there the interpreter is the # can swallow the program, and the common flags are listed to keep them quiet.
# program under review; here a wrapper hides the command, so the review has to _LAUNCHER_SPEC = _Spec(
# surface it (:func:`plyngent.tools.danger.classify_danger`). short="C:e:F:G:m:n:p:s:w:",
_COMMAND_STRING_OPTS: dict[str, frozenset[str]] = { long=(
"env": frozenset({"-S", "--split-string"}), "all-extras",
"su": frozenset({"-c", "--command", "--session-command"}), "all-groups",
} "all-packages",
"config=",
"constraint=",
"default-index=",
"directory=",
"env=",
"env-file=",
"extra=",
"extras=",
"file=",
"frozen",
"group=",
"index=",
"locked",
"module=",
"name=",
"no-project",
"no-sync",
"offline",
"overrides=",
"package=",
"prefix=",
"project=",
"python=",
"python-version=",
"quiet",
"refresh",
"script=",
"verbose",
"with=",
"without=",
),
launcher=True,
)
# Remote wrappers: ``ssh``/``mosh`` run their command on another machine, so the # ``ssh`` / ``mosh``: the program under review is the one after the destination,
# program under review is the one *after* the destination instead of the client. # which is the first token that is not one of their options.
# A remote wrapper with no command only opens a remote shell — what is typed or _REMOTE_WRAPPERS: dict[str, _Spec] = {
# piped into that shell never reaches this scan (see the SSH note in AGENTS.md). "mosh": _Spec(
_REMOTE_WRAPPERS: frozenset[str] = frozenset({"mosh", "ssh"}) short="46hP:p:v",
long=("client-port=", "family=", "help", "no-ssh-pty", "port=", "predict=", "server=", "ssh="),
# Length of a short option (``-p``), so ``-p2222`` reads as option plus value. remote=True,
_SHORT_OPTION = 2 ),
"ssh": _Spec(
# Remote-client options that consume a value (``ssh -o BatchMode=yes -p 2222``). short="46AaCfgGKkMNnqSTtVvXxYysB:b:c:D:E:e:F:I:i:J:L:l:m:O:o:P:p:Q:R:S:W:w:",
_REMOTE_VALUE_OPTS: dict[str, frozenset[str]] = { remote=True,
"mosh": frozenset({"-p", "--client-port", "--family", "--port", "--predict", "--server", "--ssh"}),
"ssh": frozenset(
{
"-B",
"-b",
"-c",
"-D",
"-E",
"-e",
"-F",
"-I",
"-i",
"-J",
"-L",
"-l",
"-m",
"-O",
"-o",
"-P",
"-p",
"-Q",
"-R",
"-S",
"-W",
"-w",
}
), ),
} }
@@ -291,6 +353,8 @@ class UnwrappedCommand:
command_strings: tuple[CommandString, ...] = () command_strings: tuple[CommandString, ...] = ()
#: The remote hop, when the command runs on another machine. #: The remote hop, when the command runs on another machine.
remote: RemoteTarget | None = None remote: RemoteTarget | None = None
#: An option the wrapper's spec does not know: the program behind it is a guess.
unparsed: str = ""
@property @property
def command(self) -> tuple[str, ...]: def command(self) -> tuple[str, ...]:
@@ -342,36 +406,39 @@ def unwrap_command(argv: Sequence[str]) -> UnwrappedCommand:
"""Resolve *argv* to the program behind any wrappers and ``run`` launchers. """Resolve *argv* to the program behind any wrappers and ``run`` launchers.
Wrappers are unwrapped outer → inner as long as a program still follows Wrappers are unwrapped outer → inner as long as a program still follows
them; their own options and ``FOO=bar`` assignments are skipped, and an them; their own options and ``FOO=bar`` assignments are read by ``getopt``
option that carries a command line is collected in ``command_strings``. (:func:`_parse_options`), and an option that carries a command line is
collected in ``command_strings``.
``ssh`` / ``mosh`` are unwrapped the same way — the program under review is ``ssh`` / ``mosh`` are unwrapped the same way — the program under review is
the one after the destination (``ssh prod rm -rf /`` runs ``rm``), and the the one after the destination (``ssh prod rm -rf /`` runs ``rm``), and the
hop is reported in :attr:`UnwrappedCommand.remote`. A client with no command hop is reported in :attr:`UnwrappedCommand.remote`. A client with no command
opens a remote shell (``remote.shell``); nothing behind it can be resolved opens a remote shell (``remote.shell``); nothing behind it can be resolved
here. here.
An option the wrapper's spec does not know stops the unwrap at that wrapper
and is reported in :attr:`UnwrappedCommand.unparsed`: the program behind it
is unknown, so the review asks instead of guessing (the wrapper itself is
still judged, so ``sudo <new flag> rm -rf /`` keeps its ``sudo`` prompt).
""" """
parts = list(argv) parts = list(argv)
wrappers: list[str] = [] wrappers: list[str] = []
command_strings: list[CommandString] = [] command_strings: list[CommandString] = []
remote: RemoteTarget | None = None remote: RemoteTarget | None = None
unparsed = ""
index = 0 index = 0
while index < len(parts): while index < len(parts):
name = basename(parts[index]) name = basename(parts[index])
if name in _REMOTE_WRAPPERS: step = _step(name, parts, index + 1, command_strings=command_strings)
start = _skip_remote(name, parts, index + 1) if step.unparsed:
if start is not None: unparsed = f"{name} {step.unparsed}"
remote = start.target break
if start.index is None: if step.remote is not None:
break # a remote shell: the client is the whole command remote = step.remote
wrappers.append(name) if step.index is None:
index = start.index
continue
nxt = _skip_wrapper(name, parts, index + 1, command_strings=command_strings)
if nxt is None:
break break
wrappers.append(name) wrappers.append(name)
index = nxt index = step.index
base = basename(parts[index]) if index < len(parts) else "" base = basename(parts[index]) if index < len(parts) else ""
chain = dict.fromkeys((*wrappers, base)) chain = dict.fromkeys((*wrappers, base))
return UnwrappedCommand( return UnwrappedCommand(
@@ -382,155 +449,107 @@ def unwrap_command(argv: Sequence[str]) -> UnwrappedCommand:
self_review=tuple(name for name in chain if name in _SELF_REVIEW), self_review=tuple(name for name in chain if name in _SELF_REVIEW),
command_strings=tuple(command_strings), command_strings=tuple(command_strings),
remote=remote, remote=remote,
unparsed=unparsed,
) )
@dataclass(frozen=True) @dataclass(frozen=True)
class _RemoteStart: class _Parsed:
"""Where a remote client's own command starts, and the hop it reaches.""" """A wrapper's own options, where its command starts, and what it did not know."""
#: Index of the remote command, or ``None`` when only a shell opens. options: tuple[tuple[str, str], ...] = ()
index: int | None index: int = 0
target: RemoteTarget unparsed: str = ""
def _skip_remote(name: str, parts: list[str], start: int) -> _RemoteStart | None: @dataclass(frozen=True)
"""Where the command *name* runs elsewhere starts; ``None`` without a hop. class _Step:
"""One wrapper resolved: its command, the hop it reaches, or its parse error."""
The destination is the first token that is not one of the client's own index: int | None = None
options (``ssh -o BatchMode=yes -p 2222 [user@]host …``); the command follows unparsed: str = ""
it, or — for ``mosh`` — follows an explicit ``--``. No token after the remote: RemoteTarget | None = None
destination means the client just opens a remote shell.
"""
value_opts = _REMOTE_VALUE_OPTS.get(name, frozenset()) def _step(name: str, parts: list[str], start: int, *, command_strings: list[CommandString]) -> _Step:
"""Resolve the wrapper *name* at ``parts[start - 1]`` past its own options."""
spec = _spec_for(name)
if spec is None:
return _Step()
index = start index = start
host = "" if spec.launcher:
batch = False if index >= len(parts) or parts[index] != "run":
while index < len(parts): return _Step() # ``npm install``: the launcher is the whole command
token = parts[index] index += 1
if not token.startswith("-"): parsed = _parse_options(parts, index, spec)
host = token if parsed.unparsed:
index += 1 return _Step(unparsed=parsed.unparsed)
break for option, value in parsed.options:
option, value, consumed = _remote_option(token, parts, index, value_opts=value_opts) if option in spec.command_opts:
index += consumed command_strings.append(CommandString(wrapper=name, option=option, text=value))
if option == "-o" and value is not None and value.partition("=")[0].strip().lower() == "batchmode": index = _skip_prefixes(parts, parsed.index, spec)
batch = value.partition("=")[2].strip().lower() in {"1", "on", "true", "yes"} if spec.remote:
if not host: return _remote_step(name, parts, index, parsed.options)
return None return _Step(index=index) if index < len(parts) else _Step()
def _spec_for(name: str) -> _Spec | None:
"""The option spec of a wrapper, or ``None`` when *name* wraps nothing."""
spec = _WRAPPERS.get(name) or _REMOTE_WRAPPERS.get(name)
if spec is None and name in _RUN_LAUNCHERS:
return _LAUNCHER_SPEC
return spec
def _skip_prefixes(parts: list[str], index: int, spec: _Spec) -> int:
"""Skip the ``FOO=bar`` assignments and the bare duration a wrapper takes first."""
while index < len(parts) and _ASSIGNMENT.match(parts[index]):
index += 1
if spec.duration and index < len(parts) and _DURATION.fullmatch(parts[index]):
index += 1
return index
def _parse_options(parts: list[str], start: int, spec: _Spec) -> _Parsed:
"""Read a wrapper's own options, stopping at the first token that is not one.
``getopt`` (POSIX order — not ``gnu_getopt``, which permutes) consumes the
wrapper's options and leaves the rest, so the length of that remainder is
where the wrapped program starts. Jammed forms (``-p2222``,
``-oBatchMode=yes``, ``--with=pkg``) are its business, not ours. An option
the spec does not know is *not* skipped as a flag — that is how a flag ended
up eating the program — but reported, so the review can ask.
"""
try:
options, rest = getopt.getopt(list(parts[start:]), spec.short, list(spec.long))
except getopt.GetoptError as exc:
return _Parsed(index=start, unparsed=f"(cannot read its options: {exc})")
return _Parsed(options=tuple(options), index=len(parts) - len(rest))
def _remote_step(name: str, parts: list[str], index: int, options: Sequence[tuple[str, str]]) -> _Step:
"""The hop a remote client reaches, and where its command starts (if any).
The destination is the first token that is not one of the client's options;
the command follows it, or — for ``mosh`` — follows an explicit ``--``. No
token after the destination means the client just opens a remote shell.
"""
if index >= len(parts):
return _Step()
host = parts[index]
index += 1
if name == "mosh" and index < len(parts) and parts[index] == "--": if name == "mosh" and index < len(parts) and parts[index] == "--":
index += 1 index += 1
batch = _batch_mode(options)
if index >= len(parts): if index >= len(parts):
return _RemoteStart(index=None, target=RemoteTarget(wrapper=name, host=host, batch=batch, shell=True)) return _Step(remote=RemoteTarget(wrapper=name, host=host, batch=batch, shell=True))
return _RemoteStart(index=index, target=RemoteTarget(wrapper=name, host=host, batch=batch)) return _Step(index=index, remote=RemoteTarget(wrapper=name, host=host, batch=batch))
def _remote_option( def _batch_mode(options: Sequence[tuple[str, str]]) -> bool:
token: str, """Whether ``-o BatchMode=yes`` was given: the client never prompts."""
parts: list[str], for option, value in options:
index: int, key, _, setting = value.partition("=")
*, if option == "-o" and key.strip().lower() == "batchmode":
value_opts: frozenset[str], return setting.strip().lower() in {"1", "on", "true", "yes"}
) -> tuple[str, str | None, int]: return False
"""The option *token* names, its value, and how many tokens it consumed.
Jammed forms carry the value in the token itself: ``-p2222`` and
``-oBatchMode=yes`` are one token, ``--ssh='ssh -p 2222'`` too.
"""
if token.startswith("--"):
option, equal, attached = token.partition("=")
if equal:
return option, attached, 1
else:
option = token[:_SHORT_OPTION]
if option in value_opts and len(token) > _SHORT_OPTION:
return option, token[_SHORT_OPTION:], 1
if option not in value_opts:
return token, None, 1
if index + 1 < len(parts):
return option, parts[index + 1], 2
return option, "", 1
def _skip_wrapper(
name: str,
parts: list[str],
start: int,
*,
command_strings: list[CommandString],
) -> int | None:
"""Index of the program *name* wraps, or ``None`` when it is the whole command."""
if name in _ENV_WRAPPERS:
nxt = _skip_options(
parts,
start,
name=name,
value_opts=_VALUE_OPTS.get(name, frozenset()),
leading_duration=name in _DURATION_WRAPPERS,
command_strings=command_strings,
)
return nxt if nxt < len(parts) else None
if name in _RUN_LAUNCHERS and start < len(parts) and parts[start] == "run":
nxt = _skip_options(
parts,
start + 1,
name=name,
value_opts=_RUN_VALUE_OPTS,
leading_duration=False,
command_strings=command_strings,
)
return nxt if nxt < len(parts) else None
return None
def _record_command_string(
name: str,
option: str,
text: str,
command_strings: list[CommandString],
) -> None:
"""Note *text* when *option* hands a command line to wrapper *name*."""
if option in _COMMAND_STRING_OPTS.get(name, frozenset()):
command_strings.append(CommandString(wrapper=name, option=option, text=text))
def _skip_options(
parts: list[str],
start: int,
*,
name: str,
value_opts: frozenset[str],
leading_duration: bool,
command_strings: list[CommandString],
) -> int:
"""Index of the first token after a wrapper's own options and assignments."""
index = start
took_duration = False
while index < len(parts):
token = parts[index]
if token == "--":
return index + 1
if _ASSIGNMENT.match(token):
index += 1
continue
if token.startswith("-"):
index += 1
if token in value_opts:
value = parts[index] if index < len(parts) else ""
_record_command_string(name, token, value, command_strings)
index += 1
continue
# Jammed forms: ``--split-string=…`` and ``-S…`` carry the value in
# the token itself, so a command line may hide there too.
option, equal, attached = token.partition("=")
if equal:
_record_command_string(name, option, attached, command_strings)
elif token[:2] in _COMMAND_STRING_OPTS.get(name, frozenset()):
_record_command_string(name, token[:2], token[2:], command_strings)
continue
if leading_duration and not took_duration and _DURATION.fullmatch(token):
took_duration = True
index += 1
continue
return index
return index
+2
View File
@@ -77,6 +77,8 @@ def _command_reason(argv: Sequence[str], *, via: str) -> DangerReason | None:
findings.append(f"remote shell on {remote.host!r} ({remote.wrapper} with no command; its input is not checked)") findings.append(f"remote shell on {remote.host!r} ({remote.wrapper} with no command; its input is not checked)")
elif remote is not None and remote.batch: elif remote is not None and remote.batch:
findings.append(f"remote command on {remote.host!r} (BatchMode=yes, no prompt on either end)") findings.append(f"remote command on {remote.host!r} (BatchMode=yes, no prompt on either end)")
if scan.unparsed:
findings.append(f"options not understood {scan.unparsed} — the wrapped program is a guess")
findings.extend(f"{span.label} runs a command line" for span in scan.command_strings) findings.extend(f"{span.label} runs a command line" for span in scan.command_strings)
findings = list(dict.fromkeys(findings)) findings = list(dict.fromkeys(findings))
if not findings: if not findings: