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")