This is an automated email from the ASF dual-hosted git repository.

potiuk 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 5bb160ebad0 Verify SSH host keys by default in the Git provider hook 
(#69103)
5bb160ebad0 is described below

commit 5bb160ebad003c845e46589974955355ded5f5cf
Author: Ephraim Anierobi <[email protected]>
AuthorDate: Sun Jun 28 15:46:10 2026 +0100

    Verify SSH host keys by default in the Git provider hook (#69103)
    
    The Git hook defaulted strict_host_key_checking to "no" and pointed the
    known_hosts file at /dev/null, so the SSH transport accepted any host key
    deploy without verification.
    
    Default to "accept-new" so a host key is trusted on first use and verified
    on every later connection. A deprecation warning announces the eventual
    move to "yes" so operators can configure known_hosts ahead of that change.
---
 providers/git/docs/connections/git.rst             |  17 +++-
 .../git/src/airflow/providers/git/hooks/git.py     |  47 ++++++++-
 providers/git/tests/unit/git/bundles/test_git.py   |   6 +-
 providers/git/tests/unit/git/hooks/test_git.py     | 108 ++++++++++++++++-----
 4 files changed, 148 insertions(+), 30 deletions(-)

diff --git a/providers/git/docs/connections/git.rst 
b/providers/git/docs/connections/git.rst
index 3f6bc8dc3a8..8bde13c1719 100644
--- a/providers/git/docs/connections/git.rst
+++ b/providers/git/docs/connections/git.rst
@@ -67,8 +67,19 @@ Extra (optional)
 
     **SSH connection options:**
 
-    * ``strict_host_key_checking``: Controls SSH strict host key checking. 
Defaults to ``no``.
-      Set to ``yes`` to enable strict checking.
+    * ``strict_host_key_checking``: Controls SSH strict host key checking. 
Accepts ``yes``,
+      ``no``, ``accept-new``, ``off`` or ``ask``. Defaults to ``accept-new``, 
which trusts a
+      server's host key on first use and then verifies it on every later 
connection (so a
+      changed key — a possible man-in-the-middle — is rejected). Set to 
``yes`` to require the
+      host key to be present in ``known_hosts`` up front, or ``no`` to disable 
verification
+      entirely (not recommended).
+
+      .. warning::
+
+         A future major release of the Git provider will change this default 
to ``yes``.
+         Deployments that rely on the default should configure 
``known_hosts_file`` (or set
+         ``strict_host_key_checking`` explicitly) now to avoid disruption when 
that happens.
+
     * ``known_hosts_file``: Path to a custom SSH known-hosts file. When
       ``strict_host_key_checking`` is ``no`` and this is not set, 
``/dev/null`` is used.
     * ``ssh_config_file``: Path to a custom SSH config file (passed as ``ssh 
-F``).
@@ -82,7 +93,7 @@ Extra (optional)
 
         {
             "key_file": "/path/to/id_rsa",
-            "strict_host_key_checking": "no"
+            "strict_host_key_checking": "accept-new"
         }
 
     Example with inline private key and passphrase:
diff --git a/providers/git/src/airflow/providers/git/hooks/git.py 
b/providers/git/src/airflow/providers/git/hooks/git.py
index e9e69367aa7..4ec8738758b 100644
--- a/providers/git/src/airflow/providers/git/hooks/git.py
+++ b/providers/git/src/airflow/providers/git/hooks/git.py
@@ -21,12 +21,15 @@ import contextlib
 import json
 import logging
 import os
+import re
 import shlex
 import stat
 import tempfile
+import warnings
 from typing import Any
 from urllib.parse import quote as urlquote
 
+from airflow.exceptions import AirflowProviderDeprecationWarning
 from airflow.providers.common.compat.sdk import AirflowException, BaseHook
 
 log = logging.getLogger(__name__)
@@ -44,7 +47,8 @@ class GitHook(BaseHook):
     * ``key_file`` — path to an SSH private key file.
     * ``private_key`` — inline SSH private key string (mutually exclusive with 
``key_file``).
     * ``private_key_passphrase`` — passphrase for the private key (key_file or 
inline).
-    * ``strict_host_key_checking`` — ``"yes"`` or ``"no"`` (default ``"no"``).
+    * ``strict_host_key_checking`` — one of ``"yes"``, ``"no"``, 
``"accept-new"``, ``"off"``
+      or ``"ask"`` (default ``"accept-new"``).
     * ``known_hosts_file`` — path to a custom SSH known-hosts file.
     * ``ssh_config_file`` — path to a custom SSH config file.
     * ``host_proxy_cmd`` — SSH ProxyCommand string (e.g. for bastion/jump 
hosts).
@@ -71,7 +75,7 @@ class GitHook(BaseHook):
                         "key_file": "optional/path/to/keyfile",
                         "private_key": "optional inline private key",
                         "private_key_passphrase": "",
-                        "strict_host_key_checking": "no",
+                        "strict_host_key_checking": "accept-new",
                         "known_hosts_file": "",
                         "ssh_config_file": "",
                         "host_proxy_cmd": "",
@@ -98,7 +102,9 @@ class GitHook(BaseHook):
         self.private_key_passphrase = extra.get("private_key_passphrase")
 
         # SSH connection options
-        self.strict_host_key_checking = extra.get("strict_host_key_checking", 
"no")
+        strict_host_key_checking = extra.get("strict_host_key_checking")
+        host_key_checking_defaulted = strict_host_key_checking is None
+        self.strict_host_key_checking = strict_host_key_checking or 
"accept-new"
         self.known_hosts_file = extra.get("known_hosts_file")
         self.ssh_config_file = extra.get("ssh_config_file")
         self.host_proxy_cmd = extra.get("host_proxy_cmd")
@@ -108,9 +114,44 @@ class GitHook(BaseHook):
 
         if self.key_file and self.private_key:
             raise AirflowException("Both 'key_file' and 'private_key' cannot 
be provided at the same time")
+
+        if host_key_checking_defaulted and self._uses_ssh_transport_options():
+            warnings.warn(
+                "The git provider connection no longer disables SSH host key 
verification by "
+                "default: 'strict_host_key_checking' now defaults to 
'accept-new' (was 'no'), so a "
+                "server's host key is trusted on first use and verified on 
every later connection. "
+                "A future major release of apache-airflow-providers-git will 
change the default to "
+                "'yes', which requires the host key to already be present in 
known_hosts. Set "
+                "'strict_host_key_checking' explicitly in the connection extra 
(and configure "
+                "'known_hosts_file') to pin the behaviour you want.",
+                AirflowProviderDeprecationWarning,
+                stacklevel=2,
+            )
         self._process_git_auth_url()
 
     _VALID_STRICT_HOST_KEY_CHECKING = frozenset({"yes", "no", "accept-new", 
"off", "ask"})
+    _SSH_REPO_URL_PATTERN = re.compile(r"^[^/@:]+@[^/:]+:")
+
+    def _uses_ssh_transport_options(self) -> bool:
+        # Heuristic: any SSH-specific option implies SSH; otherwise fall back 
to the URL scheme.
+        # A bare ssh-config Host alias (no ``user@``) without SSH options is 
not detected.
+        if any(
+            (
+                self.key_file,
+                self.private_key,
+                self.private_key_passphrase,
+                self.known_hosts_file,
+                self.ssh_config_file,
+                self.host_proxy_cmd,
+                self.ssh_port,
+            )
+        ):
+            return True
+        if not isinstance(self.repo_url, str):
+            return False
+        return self.repo_url.startswith(("ssh://", "git+ssh://")) or bool(
+            self._SSH_REPO_URL_PATTERN.match(self.repo_url)
+        )
 
     def _build_ssh_command(self, key_path: str | None = None) -> str:
         parts = ["ssh"]
diff --git a/providers/git/tests/unit/git/bundles/test_git.py 
b/providers/git/tests/unit/git/bundles/test_git.py
index 1df439f58cf..65ebbfd085e 100644
--- a/providers/git/tests/unit/git/bundles/test_git.py
+++ b/providers/git/tests/unit/git/bundles/test_git.py
@@ -102,6 +102,7 @@ class TestGitDagBundle:
                 conn_id="git_default",
                 host="[email protected]:apache/airflow.git",
                 conn_type="git",
+                extra={"strict_host_key_checking": "accept-new"},
             )
         )
         create_connection_without_db(
@@ -1003,6 +1004,7 @@ class TestGitDagBundle:
                 conn_id="my_git_connection",
                 host=repo_url,
                 conn_type="git",
+                extra={"strict_host_key_checking": "accept-new"},
                 **(extra_conn_kwargs or {}),
             )
         )
@@ -1091,6 +1093,7 @@ class TestGitDagBundle:
                 conn_id="git_default",
                 host=repo_url,
                 conn_type="git",
+                extra={"strict_host_key_checking": "accept-new"},
                 **(extra_conn_kwargs or {}),
             )
         )
@@ -1185,6 +1188,7 @@ class TestGitDagBundle:
                 conn_id="git_default",
                 host=repo_url,
                 conn_type="git",
+                extra={"strict_host_key_checking": "accept-new"},
                 **(extra_conn_kwargs or {}),
             )
         )
@@ -1300,7 +1304,7 @@ class TestGitDagBundle:
         ],
     )
     def test_repo_url_precedence(self, conn_json, repo_url, expected):
-        conn_str = json.dumps(conn_json)
+        conn_str = json.dumps({**conn_json, "extra": 
{"strict_host_key_checking": "accept-new"}})
         with patch.dict(os.environ, {"AIRFLOW_CONN_MY_TEST_GIT": conn_str}):
             bundle = GitDagBundle(
                 name="test",
diff --git a/providers/git/tests/unit/git/hooks/test_git.py 
b/providers/git/tests/unit/git/hooks/test_git.py
index 38cc6ed5354..1d750ca99c3 100644
--- a/providers/git/tests/unit/git/hooks/test_git.py
+++ b/providers/git/tests/unit/git/hooks/test_git.py
@@ -17,11 +17,14 @@
 
 from __future__ import annotations
 
+import contextlib
 import os
+import warnings
 
 import pytest
 from git import Repo
 
+from airflow.exceptions import AirflowProviderDeprecationWarning
 from airflow.models import Connection
 from airflow.providers.common.compat.sdk import AirflowException
 from airflow.providers.git.hooks.git import GitHook
@@ -122,39 +125,55 @@ class TestGitHook:
         )
 
     @pytest.mark.parametrize(
-        ("conn_id", "hook_kwargs", "expected_repo_url"),
+        ("conn_id", "hook_kwargs", "expected_repo_url", "warns_on_default"),
         [
-            (CONN_DEFAULT, {}, AIRFLOW_GIT),
-            (CONN_HTTPS, {}, 
f"https://user:{ACCESS_TOKEN}@github.com/apache/airflow.git";),
+            (CONN_DEFAULT, {}, AIRFLOW_GIT, True),
+            (CONN_HTTPS, {}, 
f"https://user:{ACCESS_TOKEN}@github.com/apache/airflow.git";, False),
             (
                 CONN_HTTPS,
                 {"repo_url": "https://github.com/apache/zzzairflow"},
                 f"https://user:{ACCESS_TOKEN}@github.com/apache/zzzairflow";,
+                False,
             ),
-            (CONN_HTTP, {}, 
f"http://user:{ACCESS_TOKEN}@github.com/apache/airflow.git";),
+            (
+                CONN_HTTPS,
+                {"repo_url": AIRFLOW_GIT},
+                AIRFLOW_GIT,
+                True,
+            ),
+            (CONN_HTTP, {}, 
f"http://user:{ACCESS_TOKEN}@github.com/apache/airflow.git";, False),
             (
                 CONN_HTTP,
                 {"repo_url": "http://github.com/apache/zzzairflow"},
                 f"http://user:{ACCESS_TOKEN}@github.com/apache/zzzairflow";,
+                False,
             ),
-            (CONN_HTTP_NO_AUTH, {}, AIRFLOW_HTTP_URL),
+            (CONN_HTTP_NO_AUTH, {}, AIRFLOW_HTTP_URL, False),
             (
                 CONN_HTTP_NO_AUTH,
                 {"repo_url": "http://github.com/apache/zzzairflow"},
                 "http://github.com/apache/zzzairflow";,
+                False,
             ),
-            (CONN_ONLY_PATH, {}, "path/to/repo"),
+            (CONN_ONLY_PATH, {}, "path/to/repo", False),
         ],
     )
-    def test_correct_repo_urls(self, conn_id, hook_kwargs, expected_repo_url):
-        hook = GitHook(git_conn_id=conn_id, **hook_kwargs)
+    def test_correct_repo_urls(self, conn_id, hook_kwargs, expected_repo_url, 
warns_on_default):
+        warning_context = (
+            pytest.warns(AirflowProviderDeprecationWarning, match="accept-new")
+            if warns_on_default
+            else contextlib.nullcontext()
+        )
+        with warning_context:
+            hook = GitHook(git_conn_id=conn_id, **hook_kwargs)
         assert hook.repo_url == expected_repo_url
 
     def test_env_var_with_configure_hook_env(self, 
create_connection_without_db):
-        default_hook = GitHook(git_conn_id=CONN_DEFAULT)
+        with pytest.warns(AirflowProviderDeprecationWarning, 
match="accept-new"):
+            default_hook = GitHook(git_conn_id=CONN_DEFAULT)
         with default_hook.configure_hook_env():
             assert default_hook.env == {
-                "GIT_SSH_COMMAND": "ssh -i /files/pkey.pem -o 
IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
+                "GIT_SSH_COMMAND": "ssh -i /files/pkey.pem -o 
IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
             }
         create_connection_without_db(
             Connection(
@@ -190,25 +209,28 @@ class TestGitHook:
             GitHook(git_conn_id=CONN_BOTH_PATH_INLINE)
 
     def test_key_file_git_hook_has_env_with_configure_hook_env(self):
-        hook = GitHook(git_conn_id=CONN_DEFAULT)
+        with pytest.warns(AirflowProviderDeprecationWarning, 
match="accept-new"):
+            hook = GitHook(git_conn_id=CONN_DEFAULT)
 
         assert hasattr(hook, "env")
         with hook.configure_hook_env():
             assert hook.env == {
-                "GIT_SSH_COMMAND": "ssh -i /files/pkey.pem -o 
IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
+                "GIT_SSH_COMMAND": "ssh -i /files/pkey.pem -o 
IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
             }
 
     def test_private_key_lazy_env_var(self):
-        hook = GitHook(git_conn_id=CONN_ONLY_INLINE_KEY)
+        with pytest.warns(AirflowProviderDeprecationWarning, 
match="accept-new"):
+            hook = GitHook(git_conn_id=CONN_ONLY_INLINE_KEY)
         assert hook.env == {}
 
         hook.set_git_env("dummy_inline_key")
         assert hook.env == {
-            "GIT_SSH_COMMAND": "ssh -i dummy_inline_key -o IdentitiesOnly=yes 
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
+            "GIT_SSH_COMMAND": "ssh -i dummy_inline_key -o IdentitiesOnly=yes 
-o StrictHostKeyChecking=accept-new"
         }
 
     def test_configure_hook_env(self):
-        hook = GitHook(git_conn_id=CONN_ONLY_INLINE_KEY)
+        with pytest.warns(AirflowProviderDeprecationWarning, 
match="accept-new"):
+            hook = GitHook(git_conn_id=CONN_ONLY_INLINE_KEY)
         assert hasattr(hook, "private_key")
 
         hook.set_git_env("dummy_inline_key")
@@ -229,7 +251,8 @@ class TestGitHook:
                 extra={"key_file": "/files/pkey.pem", "ssh_port": "2222"},
             )
         )
-        hook = GitHook(git_conn_id="git_with_port")
+        with pytest.warns(AirflowProviderDeprecationWarning, 
match="accept-new"):
+            hook = GitHook(git_conn_id="git_with_port")
         with hook.configure_hook_env():
             cmd = hook.env["GIT_SSH_COMMAND"]
             assert "-p 2222" in cmd
@@ -246,7 +269,8 @@ class TestGitHook:
                 },
             )
         )
-        hook = GitHook(git_conn_id="git_with_proxy")
+        with pytest.warns(AirflowProviderDeprecationWarning, 
match="accept-new"):
+            hook = GitHook(git_conn_id="git_with_proxy")
         with hook.configure_hook_env():
             cmd = hook.env["GIT_SSH_COMMAND"]
             assert "ProxyCommand='ssh -W %h:%p bastion.example.com'" in cmd
@@ -283,7 +307,8 @@ class TestGitHook:
                 },
             )
         )
-        hook = GitHook(git_conn_id="git_ssh_config")
+        with pytest.warns(AirflowProviderDeprecationWarning, 
match="accept-new"):
+            hook = GitHook(git_conn_id="git_ssh_config")
         with hook.configure_hook_env():
             cmd = hook.env["GIT_SSH_COMMAND"]
             assert "-F /home/user/.ssh/config" in cmd
@@ -298,7 +323,8 @@ class TestGitHook:
                 extra={"host_proxy_cmd": "ssh -W %h:%p bastion"},
             )
         )
-        hook = GitHook(git_conn_id="git_proxy_only")
+        with pytest.warns(AirflowProviderDeprecationWarning, 
match="accept-new"):
+            hook = GitHook(git_conn_id="git_proxy_only")
         assert hook.env == {}
         with hook.configure_hook_env():
             cmd = hook.env["GIT_SSH_COMMAND"]
@@ -306,13 +332,47 @@ class TestGitHook:
             assert "-i " not in cmd
             assert "ProxyCommand" in cmd
 
-    def test_default_user_known_hosts_devnull_when_no_strict_checking(self):
+    def test_user_known_hosts_devnull_when_strict_checking_disabled(self, 
create_connection_without_db):
         """When strict_host_key_checking=no and no known_hosts_file, /dev/null 
is used."""
-        hook = GitHook(git_conn_id=CONN_DEFAULT)
+        create_connection_without_db(
+            Connection(
+                conn_id="git_strict_no",
+                host=AIRFLOW_GIT,
+                conn_type="git",
+                extra={"key_file": "/files/pkey.pem", 
"strict_host_key_checking": "no"},
+            )
+        )
+        hook = GitHook(git_conn_id="git_strict_no")
         with hook.configure_hook_env():
             cmd = hook.env["GIT_SSH_COMMAND"]
+            assert "-o StrictHostKeyChecking=no" in cmd
             assert "-o UserKnownHostsFile=/dev/null" in cmd
 
+    def test_default_strict_host_key_checking_is_accept_new(self):
+        """Relying on the default verifies host keys (accept-new) and warns 
about the change."""
+        with pytest.warns(AirflowProviderDeprecationWarning, 
match="accept-new"):
+            hook = GitHook(git_conn_id=CONN_DEFAULT)
+        assert hook.strict_host_key_checking == "accept-new"
+        with hook.configure_hook_env():
+            cmd = hook.env["GIT_SSH_COMMAND"]
+            assert "-o StrictHostKeyChecking=accept-new" in cmd
+            assert "/dev/null" not in cmd
+
+    def test_explicit_strict_host_key_checking_does_not_warn(self, 
create_connection_without_db):
+        """Setting strict_host_key_checking explicitly suppresses the 
deprecation warning."""
+        create_connection_without_db(
+            Connection(
+                conn_id="git_strict_explicit",
+                host=AIRFLOW_GIT,
+                conn_type="git",
+                extra={"key_file": "/files/pkey.pem", 
"strict_host_key_checking": "accept-new"},
+            )
+        )
+        with warnings.catch_warnings():
+            warnings.simplefilter("error", AirflowProviderDeprecationWarning)
+            hook = GitHook(git_conn_id="git_strict_explicit")
+        assert hook.strict_host_key_checking == "accept-new"
+
     def test_passphrase_sets_askpass_env(self, create_connection_without_db):
         create_connection_without_db(
             Connection(
@@ -325,7 +385,8 @@ class TestGitHook:
                 },
             )
         )
-        hook = GitHook(git_conn_id="git_passphrase")
+        with pytest.warns(AirflowProviderDeprecationWarning, 
match="accept-new"):
+            hook = GitHook(git_conn_id="git_passphrase")
         with hook.configure_hook_env():
             assert "SSH_ASKPASS" in hook.env
             assert hook.env["SSH_ASKPASS_REQUIRE"] == "force"
@@ -344,7 +405,8 @@ class TestGitHook:
                 },
             )
         )
-        hook = GitHook(git_conn_id="git_passphrase_cleanup")
+        with pytest.warns(AirflowProviderDeprecationWarning, 
match="accept-new"):
+            hook = GitHook(git_conn_id="git_passphrase_cleanup")
         askpass_path = None
         with hook.configure_hook_env():
             askpass_path = hook.env.get("SSH_ASKPASS")

Reply via email to