This is an automated email from the ASF dual-hosted git repository.
shahar1 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 58af2f521bb Close the ssh askpass helper in GitHook before ssh
executes it (#73427)
58af2f521bb is described below
commit 58af2f521bb27c5a01c48d6136bcb16838ae8faa
Author: MichalJaroslawKrzywanski-TomTom <[email protected]>
AuthorDate: Mon Sep 28 15:07:42 2026 +0200
Close the ssh askpass helper in GitHook before ssh executes it (#73427)
GitHook writes the SSH_ASKPASS helper that unlocks a passphrase-protected
private key with NamedTemporaryFile(delete=True) and keeps the handle open
for
writing while ssh runs. Linux refuses to exec a file that is still open for
writing (ETXTBSY, "Text file busy"), so a clone or fetch with a
passphrase-protected key fails with "cannot exec ...: Text file busy" and
then
falls back to prompting for the passphrase. macOS does not enforce this,
which
hid the bug.
Write the helper through a small module-level context manager that closes
the
file before yielding its path and unlinks it in finally. The added test runs
the helper through configure_hook_env and asserts its output; on Linux it
fails
without the fix with "OSError: [Errno 26] Text file busy".
The token path hit the same constraint. #64105 fixed it there by replacing
GIT_ASKPASS with a credential helper written and closed before git runs,
which
is why this change is now limited to SSH_ASKPASS.
related: #73425
related: #64105
---
.../git/src/airflow/providers/git/hooks/git.py | 31 +++++++++++++++++-----
providers/git/tests/unit/git/hooks/test_git.py | 19 +++++++++++++
2 files changed, 43 insertions(+), 7 deletions(-)
diff --git a/providers/git/src/airflow/providers/git/hooks/git.py
b/providers/git/src/airflow/providers/git/hooks/git.py
index 4327c2c0ccb..c802cb169ad 100644
--- a/providers/git/src/airflow/providers/git/hooks/git.py
+++ b/providers/git/src/airflow/providers/git/hooks/git.py
@@ -37,6 +37,25 @@ from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureEx
log = logging.getLogger(__name__)
[email protected]
+def _executable_script(content: str) -> Generator[str]:
+ """
+ Write ``content`` to a private temporary file and yield its path.
+
+ git and ssh execute the askpass helpers, so the file must be closed before
+ they run: Linux refuses to exec a file that is still open for writing
+ (``ETXTBSY``, "Text file busy").
+ """
+ fd, path = tempfile.mkstemp(suffix=".sh")
+ try:
+ with os.fdopen(fd, "w") as script:
+ script.write(content)
+ os.chmod(path, stat.S_IRWXU)
+ yield path
+ finally:
+ os.unlink(path)
+
+
class GitHook(BaseHook):
"""
Hook for git repositories.
@@ -345,20 +364,18 @@ printf 'username=%s\npassword=%s\n' "$AIRFLOW_GIT_USER"
"$AIRFLOW_GIT_TOKEN"
yield
return
- with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=True)
as askpass_script:
- askpass_script.write(f"#!/bin/sh\necho
{shlex.quote(self.private_key_passphrase)}\n")
- askpass_script.flush()
- os.chmod(askpass_script.name, stat.S_IRWXU)
-
+ with _executable_script(
+ f"#!/bin/sh\necho {shlex.quote(self.private_key_passphrase)}\n"
+ ) as askpass_path:
old_askpass = os.environ.get("SSH_ASKPASS")
old_display = os.environ.get("DISPLAY")
old_askpass_require = os.environ.get("SSH_ASKPASS_REQUIRE")
try:
- os.environ["SSH_ASKPASS"] = askpass_script.name
+ os.environ["SSH_ASKPASS"] = askpass_path
os.environ["SSH_ASKPASS_REQUIRE"] = "force"
# DISPLAY must be set for SSH_ASKPASS to be used
os.environ.setdefault("DISPLAY", ":")
- self.env["SSH_ASKPASS"] = askpass_script.name
+ self.env["SSH_ASKPASS"] = askpass_path
self.env["SSH_ASKPASS_REQUIRE"] = "force"
self.env.setdefault("DISPLAY", os.environ["DISPLAY"])
yield
diff --git a/providers/git/tests/unit/git/hooks/test_git.py
b/providers/git/tests/unit/git/hooks/test_git.py
index e14a7672850..6a888852185 100644
--- a/providers/git/tests/unit/git/hooks/test_git.py
+++ b/providers/git/tests/unit/git/hooks/test_git.py
@@ -780,6 +780,25 @@ class TestGitHook:
assert "GIT_CONFIG_COUNT" not in hook.env
assert "AIRFLOW_GIT_TOKEN" not in hook.env
+ def test_passphrase_askpass_script_is_executable(self,
create_connection_without_db):
+ """ssh must be able to exec the helper: it has to be closed before it
runs (ETXTBSY on Linux)."""
+ create_connection_without_db(
+ Connection(
+ conn_id="git_passphrase_exec",
+ host=AIRFLOW_GIT,
+ conn_type="git",
+ extra={
+ "key_file": "/files/pkey.pem",
+ "private_key_passphrase": "my_secret",
+ },
+ )
+ )
+ with pytest.warns(AirflowProviderDeprecationWarning,
match="accept-new"):
+ hook = GitHook(git_conn_id="git_passphrase_exec")
+ with hook.configure_hook_env():
+ result = subprocess.run([hook.env["SSH_ASKPASS"]],
capture_output=True, text=True, check=True)
+ assert result.stdout.strip() == "my_secret"
+
# --- GitHub App auth tests ---
def test_only_app_id_without_installation_id_raises(self):