diff --git a/devel-common/pyproject.toml b/devel-common/pyproject.toml index 4dee7e82e2648..c41b325414f69 100644 --- a/devel-common/pyproject.toml +++ b/devel-common/pyproject.toml @@ -118,8 +118,7 @@ dependencies = [ "types-certifi>=2021.10.8.3", "types-croniter>=2.0.0.20240423", "types-docutils>=0.21.0.20240704", - # TODO: Bump to >= 4.0.0 once https://github.com/apache/airflow/issues/54079 - "types-paramiko>=3.4.0.20240423,<4.0.0", + "types-paramiko>=4.0.0.20260402,<5.0.0", "types-protobuf>=5.26.0.20240422", "types-python-dateutil>=2.9.0.20240316", "types-python-slugify>=8.0.2.20240310", diff --git a/providers/sftp/README.rst b/providers/sftp/README.rst index d22393492f22c..701c74d339869 100644 --- a/providers/sftp/README.rst +++ b/providers/sftp/README.rst @@ -57,7 +57,7 @@ PIP package Version required ``apache-airflow`` ``>=2.11.0`` ``apache-airflow-providers-ssh`` ``>=4.0.0`` ``apache-airflow-providers-common-compat`` ``>=1.12.0`` -``paramiko`` ``>=3.5.1,<4.0.0`` +``paramiko`` ``>=4.0.0,<5.0.0`` ``asyncssh`` ``>=2.12.0; python_version < "3.14"`` ``asyncssh`` ``>=2.22.0; python_version >= "3.14"`` ========================================== ====================================== diff --git a/providers/sftp/docs/changelog.rst b/providers/sftp/docs/changelog.rst index 44a1c54cbb377..31169cc28fcc9 100644 --- a/providers/sftp/docs/changelog.rst +++ b/providers/sftp/docs/changelog.rst @@ -27,6 +27,13 @@ Changelog --------- +Breaking changes +~~~~~~~~~~~~~~~~ + +* ``Bump minimum paramiko to 4.0.0; DSA/DSS keys are no longer supported (#54079)`` + + This provider depends on paramiko 4.0+, which dropped ``DSS``/``DSA`` keys; see `paramiko changelog `__. To migrate: create a key pair that does not use ``DSA`` (Ed25519 or RSA are typical, e.g. ``ssh-keygen -t ed25519``), add the public key to the SFTP server, then update your Airflow SFTP (or shared SSH) connection so ``key_file`` or the ``private_key`` extra uses the new key, and ensure any ``host_key`` extra is not in ``ssh-dss`` form. If you are not ready to migrate keys, stay on a provider release that still pins ``paramiko<4`` until you can switch. + 5.8.2 ..... diff --git a/providers/sftp/docs/index.rst b/providers/sftp/docs/index.rst index d760850d1a64d..1ef36f0bd42b6 100644 --- a/providers/sftp/docs/index.rst +++ b/providers/sftp/docs/index.rst @@ -104,7 +104,7 @@ PIP package Version required ``apache-airflow`` ``>=2.11.0`` ``apache-airflow-providers-ssh`` ``>=4.0.0`` ``apache-airflow-providers-common-compat`` ``>=1.12.0`` -``paramiko`` ``>=3.5.1,<4.0.0`` +``paramiko`` ``>=4.0.0,<5.0.0`` ``asyncssh`` ``>=2.12.0; python_version < "3.14"`` ``asyncssh`` ``>=2.22.0; python_version >= "3.14"`` ========================================== ====================================== diff --git a/providers/sftp/pyproject.toml b/providers/sftp/pyproject.toml index e56b35bbb6f6d..09e181bf23702 100644 --- a/providers/sftp/pyproject.toml +++ b/providers/sftp/pyproject.toml @@ -61,10 +61,9 @@ requires-python = ">=3.10" dependencies = [ "aiofiles>=23.2.0", "apache-airflow>=2.11.0", - "apache-airflow-providers-ssh>=4.0.0", + "apache-airflow-providers-ssh>=4.0.0", # use next version "apache-airflow-providers-common-compat>=1.12.0", - # TODO: Bump to >= 4.0.0 once https://github.com/apache/airflow/issues/54079 is handled - "paramiko>=3.5.1,<4.0.0", + "paramiko>=4.0.0,<5.0.0", "asyncssh>=2.12.0; python_version < '3.14'", "asyncssh>=2.22.0; python_version >= '3.14'", ] diff --git a/providers/sftp/src/airflow/providers/sftp/hooks/sftp.py b/providers/sftp/src/airflow/providers/sftp/hooks/sftp.py index bb2723489742b..cbb4254c8a304 100644 --- a/providers/sftp/src/airflow/providers/sftp/hooks/sftp.py +++ b/providers/sftp/src/airflow/providers/sftp/hooks/sftp.py @@ -802,6 +802,15 @@ def _parse_extras(self, conn: Connection) -> None: self.log.warning("No Host Key Verification. This won't protect against Man-In-The-Middle attacks") self.known_hosts = "none" elif host_key is not None: + host_key = host_key.strip() + host_key_parts = host_key.split() + if host_key_parts and host_key_parts[0] == "ssh-dss": + raise ValueError( + "DSA/DSS host keys are not supported. Paramiko 4.0 removed DSS support; " + "use an RSA, ECDSA, or Ed25519 host key and update the connection `host_key`." + ) + if len(host_key_parts) >= 2: + host_key = " ".join(host_key_parts[:2]) self.known_hosts = f"{conn.host} {host_key}".encode() async def _get_conn(self) -> asyncssh.SSHClientConnection: diff --git a/providers/sftp/tests/unit/sftp/hooks/test_sftp.py b/providers/sftp/tests/unit/sftp/hooks/test_sftp.py index 41ab0878ec410..bd4c2f05252fa 100644 --- a/providers/sftp/tests/unit/sftp/hooks/test_sftp.py +++ b/providers/sftp/tests/unit/sftp/hooks/test_sftp.py @@ -817,6 +817,10 @@ async def test_extra_dejson_fields_for_connection_building_known_hosts_none( ("mock_port", "mock_host_key"), [ (22, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFe8P8lk5HFfL/rMlcCMHQhw1cg+uZtlK5rXQk2C4pOY"), + ( + 22, + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFe8P8lk5HFfL/rMlcCMHQhw1cg+uZtlK5rXQk2C4pOY user@host", + ), (2222, "AAAAC3NzaC1lZDI1NTE5AAAAIFe8P8lk5HFfL/rMlcCMHQhw1cg+uZtlK5rXQk2C4pOY"), ( 2222, @@ -848,7 +852,22 @@ async def test_extra_dejson_fields_for_connection_with_host_key( hook = SFTPHookAsync() await hook._get_conn() - assert hook.known_hosts == f"localhost {mock_host_key}".encode() + host_key_parts = mock_host_key.split() + expected_host_key = " ".join(host_key_parts[:2]) if len(host_key_parts) >= 2 else mock_host_key + assert hook.known_hosts == f"localhost {expected_host_key}".encode() + + @patch("asyncssh.connect", new_callable=AsyncMock) + @patch("airflow.providers.sftp.hooks.sftp.get_async_connection") + @pytest.mark.asyncio + async def test_parse_extras_dss_host_key_raises(self, mock_get_connection, mock_connect): + """Test that ssh-dss host_key is rejected.""" + mock_get_connection.return_value = MockAirflowConnectionWithHostKey( + host_key="ssh-dss\tAAAAB3...", no_host_key_check=False + ) + + hook = SFTPHookAsync() + with pytest.raises(ValueError, match="DSA/DSS host keys"): + await hook._get_conn() @patch("asyncssh.connect", new_callable=AsyncMock) @patch("airflow.providers.sftp.hooks.sftp.get_async_connection") diff --git a/providers/ssh/README.rst b/providers/ssh/README.rst index 0225f32ec68fc..9886eedea4ad8 100644 --- a/providers/ssh/README.rst +++ b/providers/ssh/README.rst @@ -56,7 +56,7 @@ PIP package Version required ``apache-airflow`` ``>=2.11.0`` ``apache-airflow-providers-common-compat`` ``>=1.12.0`` ``asyncssh`` ``>=2.12.0`` -``paramiko`` ``>=3.5.1,<4.0.0`` +``paramiko`` ``>=4.0.0,<5.0.0`` ========================================== ================== The changelog for the provider package can be found in the diff --git a/providers/ssh/docs/changelog.rst b/providers/ssh/docs/changelog.rst index 0e391e0aa0830..94236ac76e46b 100644 --- a/providers/ssh/docs/changelog.rst +++ b/providers/ssh/docs/changelog.rst @@ -27,6 +27,13 @@ Changelog --------- +Breaking changes +~~~~~~~~~~~~~~~~ + +* ``Bump minimum paramiko to 4.0.0; DSA/DSS private keys and ssh-dss host keys are no longer supported (#54079)`` + + Paramiko 4.0 removed DSS/DSA support; see `paramiko changelog `__ for upstream details. If you use a DSA private key in an SSH connection, generate a new key (for example ``ssh-keygen -t ed25519`` or ``-t rsa``), install the public key on the server, and point your Airflow connection at the new key file or ``private_key`` extra. If you pin the remote host with a ``host_key`` extra in ``ssh-dss`` form, obtain the server's current RSA, ECDSA, or Ed25519 host key and replace the value. The same constraints apply to SFTP connections that rely on paramiko via the SSH provider. + 5.0.4 ..... diff --git a/providers/ssh/docs/connections/ssh.rst b/providers/ssh/docs/connections/ssh.rst index a248a60477c4f..9ca7ce0dedfac 100644 --- a/providers/ssh/docs/connections/ssh.rst +++ b/providers/ssh/docs/connections/ssh.rst @@ -54,7 +54,7 @@ Extra (optional) * ``no_host_key_check`` - Set to ``false`` to restrict connecting to hosts with no entries in ``~/.ssh/known_hosts`` (Hosts file). This provides maximum protection against trojan horse attacks, but can be troublesome when the ``/etc/ssh/ssh_known_hosts`` file is poorly maintained or connections to new hosts are frequently made. This option forces the user to manually add all new hosts. Default is ``true``, ssh will automatically add new host keys to the user known hosts files. * ``allow_host_key_change`` - Set to ``true`` if you want to allow connecting to hosts that has host key changed or when you get 'REMOTE HOST IDENTIFICATION HAS CHANGED' error. This won't protect against Man-In-The-Middle attacks. Other possible solution is to remove the host entry from ``~/.ssh/known_hosts`` file. Default is ``false``. * ``look_for_keys`` - Set to ``false`` if you want to disable searching for discoverable private key files in ``~/.ssh/`` - * ``host_key`` - The base64 encoded ssh-rsa public key of the host or "ssh- " (as you would find in the ``known_hosts`` file). Specifying this allows making the connection if and only if the public key of the endpoint matches this value. + * ``host_key`` - The base64 encoded ssh-rsa public key of the host or ``" "`` (as you would find in the ``known_hosts`` file). Specifying this allows making the connection if and only if the public key of the endpoint matches this value. Supported key type strings are ``ssh-rsa``, ``ecdsa-sha2-nistp256``, ``ecdsa-sha2-nistp384``, ``ecdsa-sha2-nistp521``, and ``ssh-ed25519``; the legacy ``ssh-ecdsa`` form is also accepted for compatibility. DSA/DSS (``ssh-dss``) host keys are not supported because `paramiko 4.0 removed DSS support `__; generate a new host key and update this field. * ``disabled_algorithms`` - A dictionary mapping algorithm type to an iterable of algorithm identifiers, which will be disabled for the lifetime of the transport. * ``ciphers`` - A list of ciphers to use in order of preference. diff --git a/providers/ssh/docs/index.rst b/providers/ssh/docs/index.rst index fd5f5b1ca8dbf..ecd5f426061e5 100644 --- a/providers/ssh/docs/index.rst +++ b/providers/ssh/docs/index.rst @@ -95,7 +95,7 @@ PIP package Version required ``apache-airflow`` ``>=2.11.0`` ``apache-airflow-providers-common-compat`` ``>=1.12.0`` ``asyncssh`` ``>=2.12.0`` -``paramiko`` ``>=3.5.1,<4.0.0`` +``paramiko`` ``>=4.0.0,<5.0.0`` ========================================== ================== Downloading official packages diff --git a/providers/ssh/pyproject.toml b/providers/ssh/pyproject.toml index abff218a422b2..59114e621dbde 100644 --- a/providers/ssh/pyproject.toml +++ b/providers/ssh/pyproject.toml @@ -62,8 +62,7 @@ dependencies = [ "apache-airflow>=2.11.0", "apache-airflow-providers-common-compat>=1.12.0", "asyncssh>=2.12.0", - # TODO: Bump to >= 4.0.0 once https://github.com/apache/airflow/issues/54079 is handled - "paramiko>=3.5.1,<4.0.0", + "paramiko>=4.0.0,<5.0.0", ] diff --git a/providers/ssh/src/airflow/providers/ssh/hooks/ssh.py b/providers/ssh/src/airflow/providers/ssh/hooks/ssh.py index 54b7edf6008c7..ec8e37d67e35a 100644 --- a/providers/ssh/src/airflow/providers/ssh/hooks/ssh.py +++ b/providers/ssh/src/airflow/providers/ssh/hooks/ssh.py @@ -50,6 +50,16 @@ def is_arg_set(value): # type: ignore[misc,no-redef] CMD_TIMEOUT = 10 +_HostKeyConstructor = type[paramiko.RSAKey] | type[paramiko.ECDSAKey] | type[paramiko.Ed25519Key] +_SUPPORTED_HOST_KEY_TYPES = ( + "ssh-rsa", + "ssh-ecdsa", + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp521", + "ssh-ed25519", +) + class SSHHook(BaseHook): """ @@ -87,19 +97,21 @@ class SSHHook(BaseHook): once and some connections are transiently refused (e.g. ``sshd`` ``MaxStartups`` throttling). """ - # List of classes to try loading private keys as, ordered (roughly) by most common to least common + # List of classes to try loading private keys as, ordered (roughly) by most common to least common. + # DSA/DSS keys are not supported (removed in paramiko 4.0). _pkey_loaders: Sequence[type[paramiko.PKey]] = ( paramiko.RSAKey, paramiko.ECDSAKey, paramiko.Ed25519Key, - paramiko.DSSKey, ) - _host_key_mappings = { - "rsa": paramiko.RSAKey, - "dss": paramiko.DSSKey, - "ecdsa": paramiko.ECDSAKey, - "ed25519": paramiko.Ed25519Key, + _host_key_mappings: dict[str, _HostKeyConstructor] = { + "ssh-rsa": paramiko.RSAKey, + "ssh-ecdsa": paramiko.ECDSAKey, + "ecdsa-sha2-nistp256": paramiko.ECDSAKey, + "ecdsa-sha2-nistp384": paramiko.ECDSAKey, + "ecdsa-sha2-nistp521": paramiko.ECDSAKey, + "ssh-ed25519": paramiko.Ed25519Key, } conn_name_attr = "ssh_conn_id" @@ -227,11 +239,25 @@ def __init__( self.ciphers = extra_options.get("ciphers") if host_key is not None: - if host_key.startswith("ssh-"): - key_type, host_key = host_key.split(None)[:2] - key_constructor = self._host_key_mappings[key_type[4:]] - else: - key_constructor = paramiko.RSAKey + host_key = host_key.strip() + host_key_parts = host_key.split() + key_constructor: _HostKeyConstructor = paramiko.RSAKey + if len(host_key_parts) >= 2: + key_type, host_key = host_key_parts[:2] + if key_type == "ssh-dss": + raise ValueError( + "DSA/DSS host keys are not supported. Paramiko 4.0 removed DSS support; " + "use an RSA, ECDSA, or Ed25519 host key and update the connection `host_key`." + ) + key_constructor_for_type = self._host_key_mappings.get(key_type) + if key_constructor_for_type is None: + raise ValueError( + f"Unsupported SSH host key algorithm {key_type!r}. " + f"Supported types are: {', '.join(_SUPPORTED_HOST_KEY_TYPES)}." + ) + key_constructor = key_constructor_for_type + elif host_key in self._host_key_mappings or host_key == "ssh-dss": + raise ValueError(f"SSH host key {host_key!r} is missing key data.") decoded_host_key = decodebytes(host_key.encode("utf-8")) self.host_key = key_constructor(data=decoded_host_key) self.no_host_key_check = False @@ -423,9 +449,9 @@ def _pkey_from_private_key(self, private_key: str, passphrase: str | None = None except (paramiko.ssh_exception.SSHException, ValueError): continue raise AirflowException( - "Private key provided cannot be read by paramiko." - "Ensure key provided is valid for one of the following" - "key formats: RSA, DSS, ECDSA, or Ed25519" + "Private key provided cannot be read by paramiko. " + "Ensure key provided is valid for one of the following " + "key formats: RSA, ECDSA, or Ed25519." ) def exec_ssh_client_command( @@ -599,6 +625,15 @@ def _parse_extras(self, conn: Any) -> None: self.log.warning("No Host Key Verification. This won't protect against Man-In-The-Middle attacks") self.known_hosts = "none" elif host_key is not None: + host_key = host_key.strip() + host_key_parts = host_key.split() + if host_key_parts and host_key_parts[0] == "ssh-dss": + raise ValueError( + "DSA/DSS host keys are not supported. Paramiko 4.0 removed DSS support; " + "use an RSA, ECDSA, or Ed25519 host key and update the connection `host_key`." + ) + if len(host_key_parts) >= 2: + host_key = " ".join(host_key_parts[:2]) self.known_hosts = f"{conn.host} {host_key}".encode() async def _get_conn(self): diff --git a/providers/ssh/tests/unit/ssh/hooks/test_ssh.py b/providers/ssh/tests/unit/ssh/hooks/test_ssh.py index e277c1dc3f654..baf1847ec9af5 100644 --- a/providers/ssh/tests/unit/ssh/hooks/test_ssh.py +++ b/providers/ssh/tests/unit/ssh/hooks/test_ssh.py @@ -26,6 +26,8 @@ import paramiko import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec, ed25519 from airflow.models import Connection from airflow.providers.common.compat.sdk import AirflowException @@ -63,12 +65,28 @@ def generate_host_key(pkey: paramiko.PKey): return key_obj.get_base64() +def generate_ed25519_host_key(): + private_key = ed25519.Ed25519PrivateKey.generate() + private_key_text = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.OpenSSH, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + return paramiko.Ed25519Key(file_obj=StringIO(private_key_text)).get_base64() + + TEST_PKEY = paramiko.RSAKey.generate(4096) TEST_PRIVATE_KEY = generate_key_string(pkey=TEST_PKEY) TEST_HOST_KEY = generate_host_key(pkey=TEST_PKEY) TEST_PKEY_ECDSA = paramiko.ECDSAKey.generate() TEST_PRIVATE_KEY_ECDSA = generate_key_string(pkey=TEST_PKEY_ECDSA) +TEST_HOST_KEY_ECDSA = TEST_PKEY_ECDSA.get_base64() +TEST_PKEY_ECDSA_P384 = paramiko.ECDSAKey.generate(curve=ec.SECP384R1()) +TEST_HOST_KEY_ECDSA_P384 = TEST_PKEY_ECDSA_P384.get_base64() +TEST_PKEY_ECDSA_P521 = paramiko.ECDSAKey.generate(curve=ec.SECP521R1()) +TEST_HOST_KEY_ECDSA_P521 = TEST_PKEY_ECDSA_P521.get_base64() +TEST_HOST_KEY_ED25519 = generate_ed25519_host_key() TEST_TIMEOUT = 20 TEST_CONN_TIMEOUT = 30 @@ -574,6 +592,106 @@ def test_ssh_connection_with_host_key_extra_with_type(self, ssh_client): hook.remote_host, "ssh-rsa", hook.host_key ) + @pytest.mark.parametrize( + ("key_type", "host_key", "expected_cls", "expected_name"), + [ + ("ecdsa-sha2-nistp256", TEST_HOST_KEY_ECDSA, paramiko.ECDSAKey, "ecdsa-sha2-nistp256"), + ("ssh-ecdsa", TEST_HOST_KEY_ECDSA, paramiko.ECDSAKey, "ecdsa-sha2-nistp256"), + ("ecdsa-sha2-nistp384", TEST_HOST_KEY_ECDSA_P384, paramiko.ECDSAKey, "ecdsa-sha2-nistp384"), + ("ecdsa-sha2-nistp521", TEST_HOST_KEY_ECDSA_P521, paramiko.ECDSAKey, "ecdsa-sha2-nistp521"), + ("ssh-ed25519", TEST_HOST_KEY_ED25519, paramiko.Ed25519Key, "ssh-ed25519"), + ( + "ssh-ed25519", + f"{TEST_HOST_KEY_ED25519} user@host", + paramiko.Ed25519Key, + "ssh-ed25519", + ), + ], + ) + @mock.patch.object(SSHHook, "get_connection") + def test_typed_host_key_in_connection_extra_is_supported( + self, mock_get_connection, key_type, host_key, expected_cls, expected_name + ): + mock_get_connection.return_value = Connection( + conn_id="ssh_typed_host_key", + conn_type="ssh", + host="remote_host", + login="user", + extra=json.dumps( + { + "host_key": f"{key_type} {host_key}", + "no_host_key_check": False, + } + ), + ) + hook = SSHHook(ssh_conn_id="ssh_typed_host_key") + + assert isinstance(hook.host_key, expected_cls) + assert hook.host_key.get_name() == expected_name + + @pytest.mark.parametrize( + ("host_key", "expected_name"), + [ + (f" {TEST_HOST_KEY} ", "ssh-rsa"), + (f"ssh-rsa\t{TEST_HOST_KEY}", "ssh-rsa"), + (f"ssh-ecdsa\t{TEST_HOST_KEY_ECDSA}", "ecdsa-sha2-nistp256"), + ], + ) + @mock.patch.object(SSHHook, "get_connection") + def test_host_key_extra_accepts_legacy_and_copy_paste_whitespace( + self, mock_get_connection, host_key, expected_name + ): + mock_get_connection.return_value = Connection( + conn_id="ssh_host_key_with_whitespace", + conn_type="ssh", + host="remote_host", + login="user", + extra=json.dumps({"host_key": host_key, "no_host_key_check": False}), + ) + hook = SSHHook(ssh_conn_id="ssh_host_key_with_whitespace") + + assert hook.host_key is not None + assert hook.host_key.get_name() == expected_name + + @mock.patch.object(SSHHook, "get_connection") + def test_dss_host_key_in_connection_extra_raises(self, mock_get_connection): + mock_get_connection.return_value = Connection( + conn_id="ssh_dss_host_key", + conn_type="ssh", + host="remote_host", + login="user", + extra=json.dumps({"host_key": "ssh-dss AAAAB3NzaC1kc3MAAA==", "no_host_key_check": False}), + ) + with pytest.raises(ValueError, match="DSA/DSS host keys"): + SSHHook(ssh_conn_id="ssh_dss_host_key") + + @mock.patch.object(SSHHook, "get_connection") + def test_dss_host_key_with_tab_in_connection_extra_raises(self, mock_get_connection): + mock_get_connection.return_value = Connection( + conn_id="ssh_dss_host_key", + conn_type="ssh", + host="remote_host", + login="user", + extra=json.dumps({"host_key": "ssh-dss\tAAAAB3NzaC1kc3MAAA==", "no_host_key_check": False}), + ) + with pytest.raises(ValueError, match="DSA/DSS host keys"): + SSHHook(ssh_conn_id="ssh_dss_host_key") + + @pytest.mark.parametrize("key_type", ["ssh-fake", "ecdsa-sha2-nistp999"]) + @mock.patch.object(SSHHook, "get_connection") + def test_unsupported_host_key_algorithm_raises(self, mock_get_connection, key_type): + mock_get_connection.return_value = Connection( + conn_id="ssh_fake_alg", + conn_type="ssh", + host="remote_host", + login="user", + extra=json.dumps( + {"host_key": f"{key_type} AAAAB3NzaC1yc2EAAAADAQABAA==", "no_host_key_check": False} + ), + ) + with pytest.raises(ValueError, match=rf"Unsupported SSH host key algorithm '{key_type}'"): + SSHHook(ssh_conn_id="ssh_fake_alg") + @mock.patch("airflow.providers.ssh.hooks.ssh.paramiko.SSHClient") def test_ssh_connection_with_no_host_key_where_no_host_key_check_is_false(self, ssh_client): hook = SSHHook(ssh_conn_id=self.CONN_SSH_WITH_NO_HOST_KEY_AND_NO_HOST_KEY_CHECK_FALSE) diff --git a/providers/ssh/tests/unit/ssh/hooks/test_ssh_async.py b/providers/ssh/tests/unit/ssh/hooks/test_ssh_async.py index 1e56fb4c37257..9001bf3ca7f35 100644 --- a/providers/ssh/tests/unit/ssh/hooks/test_ssh_async.py +++ b/providers/ssh/tests/unit/ssh/hooks/test_ssh_async.py @@ -75,12 +75,22 @@ def test_parse_extras_host_key(self): """Test parsing host_key from connection extras.""" hook = SSHHookAsync(ssh_conn_id="test_conn") mock_conn = mock.MagicMock() - mock_conn.extra_dejson = {"host_key": "ssh-rsa AAAAB3...", "no_host_key_check": "false"} + mock_conn.extra_dejson = {"host_key": " ssh-rsa AAAAB3... user@host ", "no_host_key_check": "false"} mock_conn.host = "test.host" hook._parse_extras(mock_conn) assert hook.known_hosts == b"test.host ssh-rsa AAAAB3..." + def test_parse_extras_dss_host_key_raises(self): + """Test that ssh-dss host_key is rejected.""" + hook = SSHHookAsync(ssh_conn_id="test_conn") + mock_conn = mock.MagicMock() + mock_conn.extra_dejson = {"host_key": "ssh-dss\tAAAAB3...", "no_host_key_check": "false"} + mock_conn.host = "test.host" + + with pytest.raises(ValueError, match="DSA/DSS host keys"): + hook._parse_extras(mock_conn) + def test_parse_extras_host_key_with_no_check_raises(self): """Test that host_key with no_host_key_check raises error.""" hook = SSHHookAsync(ssh_conn_id="test_conn") diff --git a/uv.lock b/uv.lock index f08cb4583d8b2..43b6db586d290 100644 --- a/uv.lock +++ b/uv.lock @@ -2714,7 +2714,7 @@ requires-dist = [ { name = "types-deprecated", marker = "extra == 'mypy'", specifier = ">=1.2.9.20240311" }, { name = "types-docutils", marker = "extra == 'mypy'", specifier = ">=0.21.0.20240704" }, { name = "types-markdown", marker = "extra == 'mypy'", specifier = ">=3.6.0.20240316" }, - { name = "types-paramiko", marker = "extra == 'mypy'", specifier = ">=3.4.0.20240423,<4.0.0" }, + { name = "types-paramiko", marker = "extra == 'mypy'", specifier = ">=4.0.0.20260402,<5.0.0" }, { name = "types-protobuf", marker = "extra == 'mypy'", specifier = ">=5.26.0.20240422" }, { name = "types-pymysql", marker = "extra == 'mypy'", specifier = ">=1.1.0.20240425" }, { name = "types-python-dateutil", marker = "extra == 'mypy'", specifier = ">=2.9.0.20240316" }, @@ -7606,7 +7606,7 @@ requires-dist = [ { name = "apache-airflow-providers-ssh", editable = "providers/ssh" }, { name = "asyncssh", marker = "python_full_version < '3.14'", specifier = ">=2.12.0" }, { name = "asyncssh", marker = "python_full_version >= '3.14'", specifier = ">=2.22.0" }, - { name = "paramiko", specifier = ">=3.5.1,<4.0.0" }, + { name = "paramiko", specifier = ">=4.0.0,<5.0.0" }, { name = "sshfs", marker = "extra == 'sshfs'", specifier = ">=2023.1.0" }, ] provides-extras = ["openlineage", "sshfs"] @@ -7883,7 +7883,7 @@ requires-dist = [ { name = "apache-airflow", editable = "." }, { name = "apache-airflow-providers-common-compat", editable = "providers/common/compat" }, { name = "asyncssh", specifier = ">=2.12.0" }, - { name = "paramiko", specifier = ">=3.5.1,<4.0.0" }, + { name = "paramiko", specifier = ">=4.0.0,<5.0.0" }, ] [package.metadata.requires-dev] @@ -14579,6 +14579,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/9c/1646ca469bc2dc299ac393c8d31136c6c22a35ca1e373fa462ac01100d37/inputimeout-1.0.4-py3-none-any.whl", hash = "sha256:f4e23d27753cfc25268eefc8d52a3edc46280ad831d226617c51882423475a43", size = 4639, upload-time = "2018-03-02T14:28:06.903Z" }, ] +[[package]] +name = "invoke" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/227c48c5fe47fa178ccf1fda8f047d16c97ba926567b661e9ce2045c600c/invoke-3.0.3.tar.gz", hash = "sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c", size = 343419, upload-time = "2026-04-07T15:17:48.307Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/de/bbc12563bbf979618d17625a4e753ff7a078523e28d870d3626daa97261a/invoke-3.0.3-py3-none-any.whl", hash = "sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053", size = 160958, upload-time = "2026-04-07T15:17:46.875Z" }, +] + [[package]] name = "ipdb" version = "0.13.13" @@ -18140,16 +18149,17 @@ all = [ [[package]] name = "paramiko" -version = "3.5.1" +version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bcrypt" }, { name = "cryptography" }, + { name = "invoke" }, { name = "pynacl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/15/ad6ce226e8138315f2451c2aeea985bf35ee910afb477bae7477dc3a8f3b/paramiko-3.5.1.tar.gz", hash = "sha256:b2c665bc45b2b215bd7d7f039901b14b067da00f3a11e6640995fd58f2664822", size = 1566110, upload-time = "2025-02-04T02:37:59.783Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/e7/81fdcbc7f190cdb058cffc9431587eb289833bdd633e2002455ca9bb13d4/paramiko-4.0.0.tar.gz", hash = "sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f", size = 1630743, upload-time = "2025-08-04T01:02:03.711Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/f8/c7bd0ef12954a81a1d3cea60a13946bd9a49a0036a5927770c461eade7ae/paramiko-3.5.1-py3-none-any.whl", hash = "sha256:43b9a0501fc2b5e70680388d9346cf252cfb7d00b0667c39e80eb43a408b8f61", size = 227298, upload-time = "2025-02-04T02:37:57.672Z" }, + { url = "https://files.pythonhosted.org/packages/a9/90/a744336f5af32c433bd09af7854599682a383b37cfd78f7de263de6ad6cb/paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9", size = 223932, upload-time = "2025-08-04T01:02:02.029Z" }, ] [[package]] @@ -23607,14 +23617,14 @@ wheels = [ [[package]] name = "types-paramiko" -version = "3.5.0.20250801" +version = "4.0.0.20260518" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/c4/4df427c835e9c795662241410732ab936c6f58be798ec25ce7015771c448/types_paramiko-3.5.0.20250801.tar.gz", hash = "sha256:e79ff84eaf44f2a5ad811743edef5d9c0cd6632a264a526f00405b87db1ec99b", size = 28838, upload-time = "2025-08-01T03:48:52.777Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/4cdb11fb6006be6309844dc5f88b6efef3f5ea5352ade42a1e9308570e28/types_paramiko-4.0.0.20260518.tar.gz", hash = "sha256:286f6830945cba63797eedf375ed87138d93198121253afe66c5d6dbcf91318d", size = 29193, upload-time = "2026-05-18T06:06:36.776Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/5b/0f0bb1e45f7547d081ae9d490a88c6f6031d0f2c97459236e0a2a8b27207/types_paramiko-3.5.0.20250801-py3-none-any.whl", hash = "sha256:3e02a0fcf2b7e7b213e0cd569f7223ff9af417052a4d149d84172ebaa6fd742e", size = 39705, upload-time = "2025-08-01T03:48:51.855Z" }, + { url = "https://files.pythonhosted.org/packages/44/a2/1a54b77758c9c175526bd0448de353f0563e71ba1ddc8bd4ac0c835deafd/types_paramiko-4.0.0.20260518-py3-none-any.whl", hash = "sha256:0ffaf1a6eb796833a49653cba4c7be13af51c8269d75234972d6239763dda270", size = 38791, upload-time = "2026-05-18T06:06:35.771Z" }, ] [[package]]