Catalyst / admin/Synapse-Cortex 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / Synapse-Cortex

public

Self Hosted ITSM Tool with RBAC/Tenanting and MFA

Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
Synapse-Cortex / Synapse-Cortexv2 / app / ai / executor.py 10330 B · main
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
"""SSH execution engine, behind a pluggable interface.

Two executors exist, each tied to a specific operator action:

* ``SimulatedSSHExecutor`` powers the **Simulate** button. It never opens a
  network connection and never inspects/logs the secret - it logs exactly what
  it *would* have run and returns a synthetic success. This is the safe
  rehearsal an operator reviews before executing live.

* ``ParamikoSSHExecutor`` powers **Execute Live on Client**. It opens a real
  SSH connection and actually runs the command on the client, capturing
  stdout/stderr and the exit status - this is how Cortex actively fixes a host.
  It auto-detects, per host, whether the login account is root: root accounts
  run the command directly, non-root accounts run it under ``sudo`` (password
  supplied on stdin), so a mixed fleet of root and non-root hosts just works.

Live execution is available on every deployment; nothing reaches a host by
accident because it is always gated behind an explicit human confirmation AND a
prior simulation of the same run (see app/routers/remediation.py).
"""
import os
import shlex
import time
from dataclasses import dataclass
from typing import Optional, Protocol

# Cap stored per-command output so a chatty command can't bloat the DB row / UI.
_MAX_OUTPUT_CHARS = 20000


@dataclass
class CommandResult:
    command: str
    output: str
    exit_status: int
    duration_ms: int


def sudo_enabled() -> bool:
    """Master switch for privilege elevation. Default ON: on a host where the
    login account is **not** root, Cortex runs the command through sudo. On a
    host where the account **is** root, no sudo is used regardless (detected at
    run time). Set SSH_USE_SUDO=false to never use sudo (e.g. a locked-down
    account that must run commands exactly as given)."""
    return os.getenv("SSH_USE_SUDO", "true").strip().lower() in ("1", "true", "yes", "on")


def build_execution_command(command: str) -> str:
    """Wrap a command so the WHOLE command runs with root privileges via sudo:
    it runs inside a shell under ``sudo -S`` (so pipes and redirects are
    elevated too), and the password is read from stdin with an empty prompt -
    the secret is never placed in the command text."""
    shell = os.getenv("SSH_SUDO_SHELL", "bash")
    return f"sudo -S -p '' -- {shell} -c {shlex.quote(command)}"


def sudo_password(secret: str) -> str:
    """Password fed to ``sudo -S`` on stdin: the connection secret by default
    (a user's login password is normally their own sudo password), overridable
    with SSH_SUDO_PASSWORD."""
    return os.getenv("SSH_SUDO_PASSWORD") or secret


class SSHExecutor(Protocol):
    def run(self, *, host: str, port: int, username: str, secret: str, command: str) -> CommandResult: ...


class SimulatedSSHExecutor:
    """Powers the Simulate button. Never opens a network connection and never
    inspects/logs the secret - it logs exactly what it *would* run. It can't
    know the target's root-ness without connecting, so it notes that sudo is
    applied automatically for a non-root account."""

    def run(self, *, host: str, port: int, username: str, secret: str, command: str) -> CommandResult:
        start = time.monotonic()
        output = (
            f"[SIMULATED] would run: {command!r} on {username}@{host}:{port} "
            f"(elevated with sudo automatically if that account is non-root)"
        )
        duration_ms = int((time.monotonic() - start) * 1000)
        return CommandResult(command=command, output=output, exit_status=0, duration_ms=duration_ms)


class ParamikoSSHExecutor:
    """Real SSH execution over paramiko. Opens a connection to
    ``username@host:port``, authenticates with the vaulted secret as a
    password, and runs the (already guardrail-checked) command.

    Privilege handling: on the first command for a given ``(host, username)``
    it probes ``id -u`` to learn whether the account is root. A root account
    runs commands directly; a non-root account runs them under ``sudo`` (the
    whole command in a shell, password on stdin) - so a fleet mixing root and
    non-root hosts is handled automatically, with no per-host configuration.
    ``SSH_USE_SUDO=false`` forces direct execution everywhere.

    Safety choices:

    * Host-key policy defaults to **trust-on-first-use** (add the key on first
      contact) so a new host just connects; set ``SSH_AUTO_ADD_HOST_KEYS=false``
      to instead **reject** hosts absent from ``known_hosts``.
    * Connect and per-command timeouts are bounded (``SSH_CONNECT_TIMEOUT`` /
      ``SSH_COMMAND_TIMEOUT`` seconds).
    * Local SSH agent and on-disk private keys are **not** consulted
      (``allow_agent=False``, ``look_for_keys=False``) - only the explicit
      vaulted credential is ever used.
    * A connection/auth failure is returned as a non-zero result (exit 255,
      the SSH convention), never raised, so the run records the failure
      cleanly instead of 500-ing. The secret is never placed in the output.
    """

    def __init__(self):
        # Cache the root-ness probe per (host, username) so a multi-step run
        # only runs `id -u` once against a given host.
        self._is_root_cache: dict[tuple[str, str], bool] = {}

    def _detect_is_root(self, client, host: str, username: str, timeout: float) -> Optional[bool]:
        key = (host, username)
        if key in self._is_root_cache:
            return self._is_root_cache[key]
        try:
            _in, out, _err = client.exec_command("id -u", timeout=timeout)
            uid = out.read().decode("utf-8", "replace").strip()
            is_root = uid == "0"
        except Exception:
            return None  # couldn't tell - caller assumes non-root (safer: use sudo)
        self._is_root_cache[key] = is_root
        return is_root

    def run(self, *, host: str, port: int, username: str, secret: str, command: str) -> CommandResult:
        start = time.monotonic()
        connect_timeout = float(os.getenv("SSH_CONNECT_TIMEOUT", "10"))
        command_timeout = float(os.getenv("SSH_COMMAND_TIMEOUT", "60"))
        # Default: trust-on-first-use so a new host connects; opt back into
        # strict verification with SSH_AUTO_ADD_HOST_KEYS=false.
        auto_add = os.getenv("SSH_AUTO_ADD_HOST_KEYS", "true").strip().lower() != "false"

        client = None
        try:
            # Imported lazily and inside the try so a deployment missing the
            # paramiko dependency records a clean failed run (exit 255) instead
            # of a 500 - constructing the executor never raises.
            import paramiko

            client = paramiko.SSHClient()
            client.load_system_host_keys()
            try:
                client.load_host_keys(os.path.expanduser("~/.ssh/known_hosts"))
            except (FileNotFoundError, OSError):
                pass
            client.set_missing_host_key_policy(paramiko.AutoAddPolicy() if auto_add else paramiko.RejectPolicy())

            client.connect(
                hostname=host,
                port=port,
                username=username,
                password=secret,
                timeout=connect_timeout,
                allow_agent=False,
                look_for_keys=False,
            )

            # Decide privilege elevation per host: sudo only when it's enabled
            # AND the account is not root (unknown root-ness -> assume non-root).
            use_sudo = False
            if sudo_enabled():
                is_root = self._detect_is_root(client, host, username, command_timeout)
                use_sudo = is_root is not True
            effective = build_execution_command(command) if use_sudo else command

            stdin, stdout, stderr = client.exec_command(effective, timeout=command_timeout)
            # For a sudo run, feed the password to `sudo -S` on stdin (never in
            # the command text), then signal EOF so a command that reads stdin
            # can't hang.
            if use_sudo:
                try:
                    stdin.write(sudo_password(secret) + "\n")
                    stdin.flush()
                    stdin.channel.shutdown_write()
                except OSError:
                    pass
            out = stdout.read().decode("utf-8", "replace")
            err = stderr.read().decode("utf-8", "replace")
            exit_status = stdout.channel.recv_exit_status()
            combined = out
            if err.strip():
                combined = (combined + "\n" if combined and not combined.endswith("\n") else combined) + err
            if len(combined) > _MAX_OUTPUT_CHARS:
                combined = combined[:_MAX_OUTPUT_CHARS] + "\n...[output truncated]"
            duration_ms = int((time.monotonic() - start) * 1000)
            return CommandResult(command=effective, output=combined, exit_status=exit_status, duration_ms=duration_ms)
        except Exception as exc:  # connect/auth/timeout/channel errors
            duration_ms = int((time.monotonic() - start) * 1000)
            # Never echo the secret; report the failure type only.
            return CommandResult(
                command=command,
                output=f"[SSH ERROR] {type(exc).__name__}: {exc}",
                exit_status=255,
                duration_ms=duration_ms,
            )
        finally:
            if client is not None:
                try:
                    client.close()
                except Exception:
                    pass


def get_simulated_executor() -> SSHExecutor:
    """Always the simulated executor - powers the **Simulate** button (the
    dry-run rehearsal an operator reviews before a live execution)."""
    return SimulatedSSHExecutor()


def get_live_executor() -> SSHExecutor:
    """The executor for the operator's **Execute Live on Client** action:
    ALWAYS the real paramiko SSH executor, on every deployment. There is no
    'demo live' - the simulated/rehearsal path is the separate Simulate button.
    Live execution is always gated behind an explicit human confirmation AND a
    prior simulation of the same run, so it never runs unattended by accident.
    (Constructing this never raises even if paramiko is missing; the run then
    records a clean failed result - see ParamikoSSHExecutor.run.)"""
    return ParamikoSSHExecutor()