diff --git a/plugins/ssh/.CHECKSUM b/plugins/ssh/.CHECKSUM index 4ecd43bd5a..4ff1a45844 100644 --- a/plugins/ssh/.CHECKSUM +++ b/plugins/ssh/.CHECKSUM @@ -1,7 +1,7 @@ { - "spec": "995ad61b7f831d806b2191f6eee722cb", - "manifest": "90a7f8d75087e034f3a72948cc72667b", - "setup": "5dce286de98dbb6fc18aeea69725e879", + "spec": "9247db0545cd8d1474eabb488c08997c", + "manifest": "58de38307d29215935d4e06a1a0943c7", + "setup": "b0941213ed47967cbec2b3e8a68bf900", "schemas": [ { "identifier": "run/schema.py", diff --git a/plugins/ssh/Dockerfile b/plugins/ssh/Dockerfile index 27b5575c65..73d4cbc824 100755 --- a/plugins/ssh/Dockerfile +++ b/plugins/ssh/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=linux/amd64 rapid7/insightconnect-python-3-slim-plugin:6.1.0 +FROM --platform=linux/amd64 rapid7/insightconnect-python-3-slim-plugin:6.2.3 LABEL organization=rapid7 LABEL sdk=python @@ -12,7 +12,7 @@ RUN if [ -f requirements.txt ]; then pip install -r requirements.txt; fi ADD . /python/src -RUN python setup.py build && python setup.py install +RUN pip install . # User to run plugin code. The two supported users are: root, nobody USER nobody diff --git a/plugins/ssh/bin/komand_ssh b/plugins/ssh/bin/komand_ssh index 93d7104dd9..dab639c435 100755 --- a/plugins/ssh/bin/komand_ssh +++ b/plugins/ssh/bin/komand_ssh @@ -6,8 +6,8 @@ from sys import argv Name = "SSH" Vendor = "rapid7" -Version = "4.0.2" -Description = "The SSH protocol is a method for secure remote login from one computer to another" +Version = "4.0.3" +Description = "[Secure Shell](https://en.wikipedia.org/wiki/Secure_Shell) (SSH) is a cryptographic network protocol for operating network services securely over an unsecured network. This plugin uses the [paramiko](http://www.paramiko.org/) to connect to a remote host via the library. The SSH plugin allows you to run commands on a remote host" def main(): diff --git a/plugins/ssh/help.md b/plugins/ssh/help.md index e2c87d0fb6..ffda218270 100644 --- a/plugins/ssh/help.md +++ b/plugins/ssh/help.md @@ -1,7 +1,6 @@ # Description -[Secure Shell](https://en.wikipedia.org/wiki/Secure_Shell) (SSH) is a cryptographic network protocol for operating network services securely over an unsecured network. -This plugin uses the [paramiko](http://www.paramiko.org/) to connect to a remote host via the library. The SSH plugin allows you to run commands on a remote host. +[Secure Shell](https://en.wikipedia.org/wiki/Secure_Shell) (SSH) is a cryptographic network protocol for operating network services securely over an unsecured network. This plugin uses the [paramiko](http://www.paramiko.org/) to connect to a remote host via the library. The SSH plugin allows you to run commands on a remote host # Key Features @@ -14,7 +13,7 @@ This plugin uses the [paramiko](http://www.paramiko.org/) to connect to a remote # Supported Product Versions -* SSH 2024-09-09 +* SSH 2025-01-15 # Documentation @@ -48,19 +47,6 @@ Example input: } ``` -The `key` field takes a base64 encoded RSA private key which must contain a newline character after the BEGIN marker and before the END marker: -E.g. - -``` ------BEGIN RSA PRIVATE KEY----- -MIIEogIBAAKCAQEA6NF8iallvQVp22WDkTkyrtvp9eWW6A8YVr+kz4TjGYe7g4h53s= -... ------END RSA PRIVATE KEY----- -``` - -You can easily encode a private key file and copy a key to your clipboard on MacOS with the following command: `base64 < .ssh/id_rsa | pbcopy`. -This can then be pasted into the Connection's `key` input field. - ## Technical Details ### Actions @@ -122,11 +108,23 @@ Example output: ## Troubleshooting - -*This plugin does not contain a troubleshooting.* + +* The `key` field in connection setup takes a base64 encoded RSA private key which must contain a newline character after the BEGIN marker and before the END marker: +E.g. + +``` +-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEA6NF8iallvQVp22WDkTkyrtvp9eWW6A8YVr+kz4TjGYe7g4h53s= +... +-----END RSA PRIVATE KEY----- +``` + +You can easily encode a private key file and copy a key to your clipboard on MacOS with the following command: `base64 < .ssh/id_rsa | pbcopy`. +This can then be pasted into the Connection's `key` input field # Version History +* 4.0.3 - Updated dependencies | Updated SDK to the latest version * 4.0.2 - Initial updates for fedramp compliance | Updated SDK to the latest version * 4.0.1 - Update from komand to insight-plugin-runtime * 4.0.0 - Upgrade the plugin runtime to `komand/python-3-37-plugin` and run as least-privileged user | Change the SSH key credential type to `credential_secret_key` to skip PEM validation in the product UI diff --git a/plugins/ssh/komand_ssh/actions/run/action.py b/plugins/ssh/komand_ssh/actions/run/action.py index 07d8c7466b..93c75d6c29 100755 --- a/plugins/ssh/komand_ssh/actions/run/action.py +++ b/plugins/ssh/komand_ssh/actions/run/action.py @@ -1,5 +1,6 @@ import insightconnect_plugin_runtime -from .schema import RunInput, RunOutput, Input, Output + +from .schema import Input, Output, RunInput, RunOutput # Custom imports below @@ -12,8 +13,8 @@ def __init__(self): def run(self, params={}): # START INPUT BINDING - DO NOT REMOVE - ANY INPUTS BELOW WILL UPDATE WITH YOUR PLUGIN SPEC AFTER REGENERATION - command = params.get(Input.COMMAND) - host = params.get(Input.HOST) + host = params.get(Input.HOST, "") + command = params.get(Input.COMMAND, "") # END INPUT BINDING - DO NOT REMOVE results = {} diff --git a/plugins/ssh/komand_ssh/connection/connection.py b/plugins/ssh/komand_ssh/connection/connection.py index 739aa64914..77d7b34cbb 100755 --- a/plugins/ssh/komand_ssh/connection/connection.py +++ b/plugins/ssh/komand_ssh/connection/connection.py @@ -1,59 +1,55 @@ -import insightconnect_plugin_runtime -from insightconnect_plugin_runtime.exceptions import PluginException +from typing import Any, Dict -from .schema import ConnectionSchema, Input +import insightconnect_plugin_runtime # Custom imports below -import base64 import paramiko -import io -from typing import Dict, Any +from insightconnect_plugin_runtime.exceptions import PluginException + +from komand_ssh.util.policies import CustomMissingKeyPolicy +from komand_ssh.util.strategies import ConnectUsingPasswordStrategy, ConnectUsingRSAKeyStrategy + +from .schema import ConnectionSchema, Input class Connection(insightconnect_plugin_runtime.Connection): def __init__(self): super(self.__class__, self).__init__(input=ConnectionSchema()) self.host = None + self.port = None + self.username = None + self.password = None + self.use_key = None + self.key = None - def connect_key(self, params: Dict[str, Any]) -> paramiko.SSHClient: - self.logger.info("Connecting via key...") - key = base64.b64decode(params.get(Input.KEY, {}).get("secretKey")).strip().decode("utf-8") - fd = io.StringIO(key) - rsa_key = paramiko.RSAKey.from_private_key(fd, password=params.get(Input.PASSWORD, {}).get("secretKey")) + def connect(self, params={}) -> None: + self.logger.info("Connecting...") + self.host = params.get(Input.HOST, "").strip() + self.port = params.get(Input.PORT, 22) + self.username = params.get(Input.USERNAME, "").strip() + self.password = params.get(Input.PASSWORD, {}).get("secretKey", "").strip() + self.use_key = params.get(Input.USE_KEY, False) + self.key = params.get(Input.KEY, {}).get("secretKey", "").strip() + def client(self, host: str = None) -> paramiko.SSHClient: + # Create fresh client instance ssh_client = paramiko.SSHClient() - ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # noqa B507 - ssh_client.load_system_host_keys() + ssh_client.set_missing_host_key_policy(CustomMissingKeyPolicy()) - ssh_client.connect( - params.get(Input.HOST), params.get(Input.PORT), username=params.get(Input.USERNAME), pkey=rsa_key - ) - return ssh_client + # Update host only if entered and different from host in connection + if host and host != self.host: + self.host = host - def connect_password(self, params: Dict[str, Any]) -> paramiko.SSHClient: - self.logger.info("Connecting via password") - ssh_client = paramiko.SSHClient() - ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # noqa B507 - ssh_client.load_system_host_keys() - ssh_client.connect( - params.get(Input.HOST), - params.get(Input.PORT), - params.get(Input.USERNAME), - params.get(Input.PASSWORD, {}).get("secretKey"), - ) - return ssh_client + # Select connection strategy + connection_strategy = ConnectUsingRSAKeyStrategy if self.use_key else ConnectUsingPasswordStrategy - def client(self, host: str = None) -> paramiko.SSHClient: - if host: - self.parameters["host"] = host - if self.parameters.get(Input.USE_KEY): - return self.connect_key(self.parameters) - else: - return self.connect_password(self.parameters) - - def connect(self, params={}) -> None: - self.logger.info("Connecting...") - self.host = params.get(Input.HOST) + # Return SSH client + try: + return connection_strategy(ssh_client, self.logger).connect( + self.host, self.port, self.username, self.password, self.key + ) + except Exception as error: + raise PluginException(preset=PluginException.Preset.UNKNOWN, data=error) def test(self) -> Dict[str, Any]: try: diff --git a/plugins/ssh/komand_ssh/util/constants.py b/plugins/ssh/komand_ssh/util/constants.py new file mode 100644 index 0000000000..6a7082b035 --- /dev/null +++ b/plugins/ssh/komand_ssh/util/constants.py @@ -0,0 +1 @@ +DEFAULT_ENCODING = "UTF-8" diff --git a/plugins/ssh/komand_ssh/util/policies.py b/plugins/ssh/komand_ssh/util/policies.py new file mode 100644 index 0000000000..915fab3a4f --- /dev/null +++ b/plugins/ssh/komand_ssh/util/policies.py @@ -0,0 +1,6 @@ +from paramiko import MissingHostKeyPolicy, PKey, SSHClient + + +class CustomMissingKeyPolicy(MissingHostKeyPolicy): + def missing_host_key(self, client: SSHClient, hostname: str, key: PKey) -> None: + client.get_host_keys().add(hostname, key.get_name(), key) diff --git a/plugins/ssh/komand_ssh/util/strategies.py b/plugins/ssh/komand_ssh/util/strategies.py new file mode 100644 index 0000000000..0e000ef9c7 --- /dev/null +++ b/plugins/ssh/komand_ssh/util/strategies.py @@ -0,0 +1,34 @@ +from abc import ABC, abstractmethod +from base64 import b64decode +from io import StringIO +from logging import Logger + +from paramiko import RSAKey, SSHClient + +from .constants import DEFAULT_ENCODING + + +class SSHConnectionStrategy(ABC): + def __init__(self, client: SSHClient, logger: Logger) -> None: + self.client = client + self.logger = logger + + @abstractmethod + def connect(self, host: str, port: int, username: str, password: str, key: str = None) -> SSHClient: + pass + + +class ConnectUsingPasswordStrategy(SSHConnectionStrategy): + def connect(self, host: str, port: int, username: str, password: str, key: str = None) -> SSHClient: + self.logger.info("Connecting to SSH server via password...") + self.client.connect(host, port, username, password) + return self.client + + +class ConnectUsingRSAKeyStrategy(SSHConnectionStrategy): + def connect(self, host: str, port: int, username: str, password: str, key: str = None) -> SSHClient: + self.logger.info("Connecting to SSH server via RSA key...") + key = b64decode(key).decode(DEFAULT_ENCODING) + rsa_key = RSAKey.from_private_key(StringIO(key), password=password) + self.client.connect(host, port, username, password, pkey=rsa_key) + return self.client diff --git a/plugins/ssh/plugin.spec.yaml b/plugins/ssh/plugin.spec.yaml index 27c6674e44..9ccd709592 100644 --- a/plugins/ssh/plugin.spec.yaml +++ b/plugins/ssh/plugin.spec.yaml @@ -3,10 +3,10 @@ extension: plugin products: [insightconnect] name: ssh title: SSH -description: The SSH protocol is a method for secure remote login from one computer to another -version: 4.0.2 +description: "[Secure Shell](https://en.wikipedia.org/wiki/Secure_Shell) (SSH) is a cryptographic network protocol for operating network services securely over an unsecured network. This plugin uses the [paramiko](http://www.paramiko.org/) to connect to a remote host via the library. The SSH plugin allows you to run commands on a remote host" +version: 4.0.3 connection_version: 4 -supported_versions: ["SSH 2024-09-09"] +supported_versions: ["SSH 2025-01-15"] vendor: rapid7 support: community status: [] @@ -24,14 +24,17 @@ hub_tags: fedramp_ready: true sdk: type: slim - version: 6.1.0 + version: 6.2.3 user: nobody key_features: - Run remote commands with SSH requirements: - Credentials for the target remote host - Address and port for the target remote host +troubleshooting: + - "The `key` field in connection setup takes a base64 encoded RSA private key which must contain a newline character after the BEGIN marker and before the END marker:\nE.g.\n\n```\n-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEA6NF8iallvQVp22WDkTkyrtvp9eWW6A8YVr+kz4TjGYe7g4h53s=\n...\n-----END RSA PRIVATE KEY-----\n```\n\nYou can easily encode a private key file and copy a key to your clipboard on MacOS with the following command: `base64 < .ssh/id_rsa | pbcopy`.\nThis can then be pasted into the Connection's `key` input field" version_history: + - "4.0.3 - Updated dependencies | Updated SDK to the latest version" - "4.0.2 - Initial updates for fedramp compliance | Updated SDK to the latest version" - "4.0.1 - Update from komand to insight-plugin-runtime" - "4.0.0 - Upgrade the plugin runtime to `komand/python-3-37-plugin` and run as least-privileged user | Change the SSH key credential type to `credential_secret_key` to skip PEM validation in the product UI" diff --git a/plugins/ssh/requirements.txt b/plugins/ssh/requirements.txt index a38a334040..d81f944a0f 100755 --- a/plugins/ssh/requirements.txt +++ b/plugins/ssh/requirements.txt @@ -1,4 +1,4 @@ # List third-party dependencies here, separated by newlines. # All dependencies must be version-pinned, eg. requests==1.2.0 # See: https://pip.pypa.io/en/stable/user_guide/#requirements-files -paramiko==3.4.1 +paramiko==3.5.0 diff --git a/plugins/ssh/setup.py b/plugins/ssh/setup.py index ded01dcc2c..3db2a45862 100755 --- a/plugins/ssh/setup.py +++ b/plugins/ssh/setup.py @@ -3,8 +3,8 @@ setup(name="ssh-rapid7-plugin", - version="4.0.2", - description="The SSH protocol is a method for secure remote login from one computer to another", + version="4.0.3", + description="[Secure Shell](https://en.wikipedia.org/wiki/Secure_Shell) (SSH) is a cryptographic network protocol for operating network services securely over an unsecured network. This plugin uses the [paramiko](http://www.paramiko.org/) to connect to a remote host via the library. The SSH plugin allows you to run commands on a remote host", author="rapid7", author_email="", url="", diff --git a/plugins/ssh/unit_test/results b/plugins/ssh/unit_test/responses/results.txt similarity index 100% rename from plugins/ssh/unit_test/results rename to plugins/ssh/unit_test/responses/results.txt diff --git a/plugins/ssh/unit_test/test_run.py b/plugins/ssh/unit_test/test_run.py index f3c89eb871..5a23c393e2 100644 --- a/plugins/ssh/unit_test/test_run.py +++ b/plugins/ssh/unit_test/test_run.py @@ -4,32 +4,25 @@ sys.path.append(os.path.abspath("../")) from unittest import TestCase -from unittest.mock import patch +from unittest.mock import MagicMock, patch from komand_ssh.actions.run import Run -from komand_ssh.actions.run.schema import Output +from komand_ssh.actions.run.schema import Input, Output from util import Util +STUB_PARAMETERS = {Input.HOST: "example.com", Input.COMMAND: "ls -l"} + class TestRun(TestCase): def setUp(self): - self.action = Run() - self.params = { - "host": "example.com", - "command": "ls -l", - } - self.action.connection = Util.default_connector() - - def mock_execute_command(self): - file1 = open("./ssh/unit_test/results", "r") - return file1, file1, file1 - - @patch("paramiko.SSHClient.set_missing_host_key_policy", return_value=None) - @patch("paramiko.SSHClient.load_system_host_keys", return_value=None) + self.action = Util.default_connector(Run()) + @patch("paramiko.SSHClient.connect", return_value=None) - @patch("paramiko.SSHClient.exec_command", side_effect=mock_execute_command) - def test_run(self, mock_key_policy, mock_host_keys, mock_connect, mock_exec): + @patch("paramiko.SSHClient.exec_command", side_effect=Util.mock_execute_command) + def test_run(self, mock_connect: MagicMock, mock_exec: MagicMock) -> None: + response = self.action.run(STUB_PARAMETERS) expected = {Output.RESULTS: {"stdout": "/home/vagrant", "stderr": "", "all_output": "/home/vagrant"}} - actual = self.action.run(self.params) - self.assertEqual(actual, expected) + self.assertEqual(response, expected) + mock_connect.assert_called() + mock_exec.assert_called() diff --git a/plugins/ssh/unit_test/util.py b/plugins/ssh/unit_test/util.py index f7bcb5f1d4..af08f472c6 100644 --- a/plugins/ssh/unit_test/util.py +++ b/plugins/ssh/unit_test/util.py @@ -1,25 +1,38 @@ import logging import os import sys +from typing import TextIO, Tuple sys.path.append(os.path.abspath("../")) +from pathlib import Path + +from insightconnect_plugin_runtime.action import Action from komand_ssh.connection.connection import Connection from komand_ssh.connection.schema import Input +STUB_CONNECTION = { + Input.HOST: "0.0.0.0", + Input.PORT: "22", + Input.KEY: {}, + Input.USE_KEY: False, + Input.PASSWORD: {"secretKey": "ABC"}, + Input.USERNAME: "username", +} + class Util: @staticmethod - def default_connector(): - connection = Connection() - params = { - Input.HOST: "0.0.0.0", - Input.PORT: "22", - Input.KEY: {}, - Input.USE_KEY: False, - Input.PASSWORD: {"secretKey": "ABC"}, - Input.USERNAME: "username", - } - connection.logger = logging.getLogger("action logger") - connection.parameters = params - return connection + def default_connector(action: Action) -> Action: + default_connection = Connection() + default_connection.logger = logging.getLogger("connection logger") + default_connection.connect(STUB_CONNECTION) + action.connection = default_connection + action.logger = logging.getLogger("action logger") + return action + + @staticmethod + def mock_execute_command(command: str) -> Tuple[TextIO, TextIO, TextIO]: + command.strip() + file_ = open(Path(__file__).parent / "responses" / "results.txt", "r") + return file_, file_, file_