mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
core/tools: judge an ssh command by what runs on the far end
The gate judged `ssh` itself: `unwrap_command` knew env wrappers and `<launcher> run`, not a remote client, so `ssh prod rm -rf /` reached `check_command_allowed` as "ssh" — not denylisted — and `classify_danger` found neither an interpreter nor a detached run. The command ran unannounced. `ssh` / `mosh` are wrappers now: their own options are skipped, the first token that is not an option is the destination, and the program under review is the one after it (after `--` for mosh). The hop is reported on the scan (`RemoteTarget`), so `ssh prod bash -c …` is reviewed with the code it carries, an escalation behind the hop keeps its confirm through YOLO, and the denylist matches the remote program (`rm`, `sudo`, `systemctl reboot`). `-o BatchMode=yes` cannot prompt at either end, so a command running with it is always confirmed, whatever program it is. A client with no command only opens a remote shell: nothing to judge at spawn time, and what is typed or piped into that shell never reaches the scan.
This commit is contained in:
@@ -19,6 +19,7 @@ if TYPE_CHECKING:
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"ESCALATION_PROGRAMS",
|
"ESCALATION_PROGRAMS",
|
||||||
"CommandString",
|
"CommandString",
|
||||||
|
"RemoteTarget",
|
||||||
"UnwrappedCommand",
|
"UnwrappedCommand",
|
||||||
"basename",
|
"basename",
|
||||||
"dash_c_code",
|
"dash_c_code",
|
||||||
@@ -174,6 +175,46 @@ _COMMAND_STRING_OPTS: dict[str, frozenset[str]] = {
|
|||||||
"su": frozenset({"-c", "--command", "--session-command"}),
|
"su": frozenset({"-c", "--command", "--session-command"}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Remote wrappers: ``ssh``/``mosh`` run their command on another machine, so the
|
||||||
|
# program under review is the one *after* the destination instead of the client.
|
||||||
|
# A remote wrapper with no command only opens a remote shell — what is typed or
|
||||||
|
# piped into that shell never reaches this scan (see the SSH note in AGENTS.md).
|
||||||
|
_REMOTE_WRAPPERS: frozenset[str] = frozenset({"mosh", "ssh"})
|
||||||
|
|
||||||
|
# Length of a short option (``-p``), so ``-p2222`` reads as option plus value.
|
||||||
|
_SHORT_OPTION = 2
|
||||||
|
|
||||||
|
# Remote-client options that consume a value (``ssh -o BatchMode=yes -p 2222``).
|
||||||
|
_REMOTE_VALUE_OPTS: dict[str, frozenset[str]] = {
|
||||||
|
"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",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
# Launcher options that consume a value (``uv run --with rich python``).
|
# Launcher options that consume a value (``uv run --with rich python``).
|
||||||
_RUN_VALUE_OPTS: frozenset[str] = frozenset(
|
_RUN_VALUE_OPTS: frozenset[str] = frozenset(
|
||||||
{
|
{
|
||||||
@@ -226,6 +267,16 @@ class CommandString:
|
|||||||
return f"{self.wrapper} {self.option}"
|
return f"{self.wrapper} {self.option}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RemoteTarget:
|
||||||
|
"""The hop a remote wrapper (``ssh`` / ``mosh``) sends its command to."""
|
||||||
|
|
||||||
|
wrapper: str
|
||||||
|
host: str
|
||||||
|
#: ``-o BatchMode=yes``: the client never prompts, so the command runs.
|
||||||
|
batch: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class UnwrappedCommand:
|
class UnwrappedCommand:
|
||||||
"""A command plus the program it really runs, past wrappers and launchers."""
|
"""A command plus the program it really runs, past wrappers and launchers."""
|
||||||
@@ -236,6 +287,8 @@ class UnwrappedCommand:
|
|||||||
base: str
|
base: str
|
||||||
self_review: tuple[str, ...]
|
self_review: tuple[str, ...]
|
||||||
command_strings: tuple[CommandString, ...] = ()
|
command_strings: tuple[CommandString, ...] = ()
|
||||||
|
#: The remote hop, when the command runs on another machine.
|
||||||
|
remote: RemoteTarget | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def command(self) -> tuple[str, ...]:
|
def command(self) -> tuple[str, ...]:
|
||||||
@@ -289,13 +342,26 @@ def unwrap_command(argv: Sequence[str]) -> UnwrappedCommand:
|
|||||||
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 skipped, and an
|
||||||
option that carries a command line is collected in ``command_strings``.
|
option that carries a command line is collected in ``command_strings``.
|
||||||
|
|
||||||
|
``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
|
||||||
|
hop is reported in :attr:`UnwrappedCommand.remote`. A client with no command
|
||||||
|
opens a remote shell, which this scan does not look into.
|
||||||
"""
|
"""
|
||||||
parts = list(argv)
|
parts = list(argv)
|
||||||
wrappers: list[str] = []
|
wrappers: list[str] = []
|
||||||
command_strings: list[CommandString] = []
|
command_strings: list[CommandString] = []
|
||||||
|
remote: RemoteTarget | None = None
|
||||||
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:
|
||||||
|
start = _skip_remote(name, parts, index + 1)
|
||||||
|
if start is not None:
|
||||||
|
remote = start.target
|
||||||
|
wrappers.append(name)
|
||||||
|
index = start.index
|
||||||
|
continue
|
||||||
nxt = _skip_wrapper(name, parts, index + 1, command_strings=command_strings)
|
nxt = _skip_wrapper(name, parts, index + 1, command_strings=command_strings)
|
||||||
if nxt is None:
|
if nxt is None:
|
||||||
break
|
break
|
||||||
@@ -310,9 +376,76 @@ def unwrap_command(argv: Sequence[str]) -> UnwrappedCommand:
|
|||||||
base=base,
|
base=base,
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _RemoteStart:
|
||||||
|
"""Where a remote client's own command starts, and the hop it reaches."""
|
||||||
|
|
||||||
|
index: int
|
||||||
|
target: RemoteTarget
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_remote(name: str, parts: list[str], start: int) -> _RemoteStart | None:
|
||||||
|
"""Where the command *name* runs elsewhere starts; ``None`` for a bare shell.
|
||||||
|
|
||||||
|
The destination is the first token that is not one of the client's own
|
||||||
|
options (``ssh -o BatchMode=yes -p 2222 [user@]host …``); the command follows
|
||||||
|
it, or — for ``mosh`` — follows an explicit ``--``. No token after the
|
||||||
|
destination means the client just opens a remote shell.
|
||||||
|
"""
|
||||||
|
value_opts = _REMOTE_VALUE_OPTS.get(name, frozenset())
|
||||||
|
index = start
|
||||||
|
host = ""
|
||||||
|
batch = False
|
||||||
|
while index < len(parts):
|
||||||
|
token = parts[index]
|
||||||
|
if not token.startswith("-"):
|
||||||
|
host = token
|
||||||
|
index += 1
|
||||||
|
break
|
||||||
|
option, value, consumed = _remote_option(token, parts, index, value_opts=value_opts)
|
||||||
|
index += consumed
|
||||||
|
if option == "-o" and value is not None and value.partition("=")[0].strip().lower() == "batchmode":
|
||||||
|
batch = value.partition("=")[2].strip().lower() in {"1", "on", "true", "yes"}
|
||||||
|
if not host:
|
||||||
|
return None
|
||||||
|
if name == "mosh" and index < len(parts) and parts[index] == "--":
|
||||||
|
index += 1
|
||||||
|
if index >= len(parts):
|
||||||
|
return None
|
||||||
|
return _RemoteStart(index=index, target=RemoteTarget(wrapper=name, host=host, batch=batch))
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_option(
|
||||||
|
token: str,
|
||||||
|
parts: list[str],
|
||||||
|
index: int,
|
||||||
|
*,
|
||||||
|
value_opts: frozenset[str],
|
||||||
|
) -> tuple[str, str | None, int]:
|
||||||
|
"""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(
|
def _skip_wrapper(
|
||||||
name: str,
|
name: str,
|
||||||
parts: list[str],
|
parts: list[str],
|
||||||
|
|||||||
@@ -59,12 +59,19 @@ def _command_reason(argv: Sequence[str], *, via: str) -> DangerReason | None:
|
|||||||
program or a wrapper) is confirmed with ``yolo_skip=False``. It never adds a
|
program or a wrapper) is confirmed with ``yolo_skip=False``. It never adds a
|
||||||
reason of its own: the denylist gate in the handler always asks about the
|
reason of its own: the denylist gate in the handler always asks about the
|
||||||
escalation, so a second prompt here would only repeat it.
|
escalation, so a second prompt here would only repeat it.
|
||||||
|
|
||||||
|
A command on another machine (``ssh``/``mosh``) is reviewed by the program it
|
||||||
|
runs there — the client's destination and options are skipped. With
|
||||||
|
``-o BatchMode=yes`` the client cannot prompt at either end, so the command
|
||||||
|
is always confirmed, whatever program it is.
|
||||||
"""
|
"""
|
||||||
scan = unwrap_command(argv)
|
scan = unwrap_command(argv)
|
||||||
interpreter = is_interpreter(scan.base)
|
interpreter = is_interpreter(scan.base)
|
||||||
findings = [f"{name} (detached run)" for name in scan.self_review]
|
findings = [f"{name} (detached run)" for name in scan.self_review]
|
||||||
if interpreter:
|
if interpreter:
|
||||||
findings.append(f"interpreter {scan.base!r}")
|
findings.append(f"interpreter {scan.base!r}")
|
||||||
|
if scan.remote is not None and scan.remote.batch:
|
||||||
|
findings.append(f"remote command on {scan.remote.host!r} (BatchMode=yes, no prompt on either end)")
|
||||||
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:
|
||||||
|
|||||||
Reference in New Issue
Block a user