diff --git a/src/plyngent/tools/command_scan.py b/src/plyngent/tools/command_scan.py index 3ca6565..c9cda28 100644 --- a/src/plyngent/tools/command_scan.py +++ b/src/plyngent/tools/command_scan.py @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Sequence -__all__ = ["UnwrappedCommand", "basename", "is_interpreter", "unwrap_command"] +__all__ = ["CommandString", "UnwrappedCommand", "basename", "is_interpreter", "unwrap_command"] # Wrappers that run the program named on their own command line. _ENV_WRAPPERS: frozenset[str] = frozenset( @@ -151,6 +151,16 @@ _VALUE_OPTS: dict[str, frozenset[str]] = { "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 +# code rather than an argument: ``env -S "rm -rf /"`` and ``su -c "…"``. An +# interpreter's own ``-c`` is the same idea, but there the interpreter is the +# program under review; here a wrapper hides the command, so the review has to +# surface it (:func:`plyngent.tools.danger.classify_danger`). +_COMMAND_STRING_OPTS: dict[str, frozenset[str]] = { + "env": frozenset({"-S", "--split-string"}), + "su": frozenset({"-c", "--command", "--session-command"}), +} + # Launcher options that consume a value (``uv run --with rich python``). _RUN_VALUE_OPTS: frozenset[str] = frozenset( { @@ -189,6 +199,20 @@ _RUN_VALUE_OPTS: frozenset[str] = frozenset( ) +@dataclass(frozen=True) +class CommandString: + """A command line a wrapper took as an option *value* (``env -S …``).""" + + wrapper: str + option: str + text: str + + @property + def label(self) -> str: + """``env -S`` — how the option is named in review output.""" + return f"{self.wrapper} {self.option}" + + @dataclass(frozen=True) class UnwrappedCommand: """A command plus the program it really runs, past wrappers and launchers.""" @@ -198,6 +222,7 @@ class UnwrappedCommand: wrappers: tuple[str, ...] base: str self_review: tuple[str, ...] + command_strings: tuple[CommandString, ...] = () @property def command(self) -> tuple[str, ...]: @@ -224,14 +249,16 @@ def unwrap_command(argv: Sequence[str]) -> UnwrappedCommand: """Resolve *argv* to the program behind any wrappers and ``run`` launchers. Wrappers are unwrapped outer → inner as long as a program still follows - them; their own options and ``FOO=bar`` assignments are skipped. + them; their own options and ``FOO=bar`` assignments are skipped, and an + option that carries a command line is collected in ``command_strings``. """ parts = list(argv) wrappers: list[str] = [] + command_strings: list[CommandString] = [] index = 0 while index < len(parts): name = basename(parts[index]) - nxt = _skip_wrapper(name, parts, index + 1) + nxt = _skip_wrapper(name, parts, index + 1, command_strings=command_strings) if nxt is None: break wrappers.append(name) @@ -244,31 +271,60 @@ def unwrap_command(argv: Sequence[str]) -> UnwrappedCommand: wrappers=tuple(wrappers), base=base, self_review=tuple(name for name in chain if name in _SELF_REVIEW), + command_strings=tuple(command_strings), ) -def _skip_wrapper(name: str, parts: list[str], start: int) -> int | None: +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, value_opts=_RUN_VALUE_OPTS, leading_duration=False) + 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 @@ -283,7 +339,17 @@ def _skip_options( 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 diff --git a/src/plyngent/tools/danger.py b/src/plyngent/tools/danger.py index 2d4f6ab..58e98fd 100644 --- a/src/plyngent/tools/danger.py +++ b/src/plyngent/tools/danger.py @@ -61,13 +61,18 @@ def _command_reason(argv: Sequence[str], *, via: str) -> str | None: forms (``env FOO=1 python …``, ``nohup pdm run python …``), and an interpreter is confirmed the same way however it runs — a bare shell, a script (``python x.py``), or a ``-c`` one-liner, whose code is printed below - ``command:`` instead of inline. + ``command:`` instead of inline. A wrapper option that takes a command line + (``env -S "…"``, ``su -c "…"``) hides code the same way, so it is confirmed + too, with the text it carries printed under its own ``