This is an automated email from the ASF dual-hosted git repository.
eladkal pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 8b057a74747 Remove DSS key support from SSH provider for Paramiko 4
(#69669)
8b057a74747 is described below
commit 8b057a74747c9aa2b2bc23703c1ec2f712d8f228
Author: Shivam Rastogi <[email protected]>
AuthorDate: Thu Jul 9 19:55:59 2026 -0700
Remove DSS key support from SSH provider for Paramiko 4 (#69669)
* Remove DSS key support for Paramiko 4
Paramiko 4 removed DSS/DSA key handling, so the SSH provider needs to stop
loading or documenting those keys while giving users a clear migration
path.\n\nThis also teaches host_key parsing to understand real OpenSSH
known_hosts key type tokens for RSA, ECDSA, and Ed25519 values.
* Fix SSH host key constructor typing
* Harden SSH host key parsing
* Raise SFTP SSH provider dependency floor
* Use next-version marker for SFTP SSH dependency
* Fix SFTP changelog spellcheck
* Preserve host key pub comment handling
Drop trailing comments from typed SSH/SFTP host_key values so pasted .pub
lines like ssh-ed25519 <key> user@host keep working. Align async SFTP host-key
parsing with async SSH by stripping values and rejecting ssh-dss.
---
devel-common/pyproject.toml | 3 +-
providers/sftp/README.rst | 2 +-
providers/sftp/docs/changelog.rst | 7 ++
providers/sftp/docs/index.rst | 2 +-
providers/sftp/pyproject.toml | 5 +-
.../sftp/src/airflow/providers/sftp/hooks/sftp.py | 9 ++
providers/sftp/tests/unit/sftp/hooks/test_sftp.py | 21 +++-
providers/ssh/README.rst | 2 +-
providers/ssh/docs/changelog.rst | 7 ++
providers/ssh/docs/connections/ssh.rst | 2 +-
providers/ssh/docs/index.rst | 2 +-
providers/ssh/pyproject.toml | 3 +-
.../ssh/src/airflow/providers/ssh/hooks/ssh.py | 65 +++++++++---
providers/ssh/tests/unit/ssh/hooks/test_ssh.py | 118 +++++++++++++++++++++
.../ssh/tests/unit/ssh/hooks/test_ssh_async.py | 12 ++-
uv.lock | 28 +++--
16 files changed, 250 insertions(+), 38 deletions(-)
diff --git a/devel-common/pyproject.toml b/devel-common/pyproject.toml
index 4dee7e82e26..c41b325414f 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 d22393492f2..701c74d3398 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 44a1c54cbb3..31169cc28fc 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 <https://www.paramiko.org/changelog.html>`__. 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 yo [...]
+
5.8.2
.....
diff --git a/providers/sftp/docs/index.rst b/providers/sftp/docs/index.rst
index d760850d1a6..1ef36f0bd42 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 e56b35bbb6f..09e181bf237 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 bb272348974..cbb4254c8a3 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 @@ class SFTPHookAsync(BaseHook):
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 41ab0878ec4..bd4c2f05252 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 @@ class TestSFTPHookAsync:
("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 @@ class TestSFTPHookAsync:
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 0225f32ec68..9886eedea4a 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 0e391e0aa08..94236ac76e4 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
<https://www.paramiko.org/changelog.html>`__ 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 E [...]
+
5.0.4
.....
diff --git a/providers/ssh/docs/connections/ssh.rst
b/providers/ssh/docs/connections/ssh.rst
index a248a60477c..9ca7ce0dedf 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-<key type> <key data>" (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
``"<key type> <key data>"`` (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``) ho [...]
* ``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 fd5f5b1ca8d..ecd5f426061 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 abff218a422..59114e621db 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 54b7edf6008..ec8e37d67e3 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 @@ except ImportError:
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 @@ class SSHHook(BaseHook):
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 @@ class SSHHook(BaseHook):
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 @@ class SSHHookAsync(BaseHook):
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 e277c1dc3f6..baf1847ec9a 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 @@ from unittest import mock
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 @@ class TestSSHHook:
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 1e56fb4c372..9001bf3ca7f 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 @@ class TestSSHHookAsync:
"""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 f08cb4583d8..43b6db586d2 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]]