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 9bc37c174d9 Prevent cleartext credential storage in Git Dag bundles
(#64105)
9bc37c174d9 is described below
commit 9bc37c174d9cd636d82d0923a129ffb4d1e69afb
Author: rjgoyln <[email protected]>
AuthorDate: Mon Sep 21 23:23:58 2026 +0800
Prevent cleartext credential storage in Git Dag bundles (#64105)
Co-authored-by: Shahar Epstein <[email protected]>
---
providers/git/docs/changelog.rst | 11 +
providers/git/docs/connections/git.rst | 12 +-
.../git/src/airflow/providers/git/bundles/git.py | 55 ++++
.../git/src/airflow/providers/git/hooks/git.py | 186 +++++++-----
providers/git/tests/unit/git/bundles/test_git.py | 225 ++++++++++++++-
providers/git/tests/unit/git/hooks/test_git.py | 317 ++++++++++++++++++++-
6 files changed, 717 insertions(+), 89 deletions(-)
diff --git a/providers/git/docs/changelog.rst b/providers/git/docs/changelog.rst
index 2155f70a40e..4cb1ba4a002 100644
--- a/providers/git/docs/changelog.rst
+++ b/providers/git/docs/changelog.rst
@@ -19,6 +19,17 @@
Changelog
---------
+.. warning::
+ Token authentication over ``http(s)`` now hands the credential to git
through a credential
+ helper configured with ``GIT_CONFIG_COUNT``, which requires git 2.31 or
newer. On git 2.31+, a
+ token connection also resets any deployment-wide ``credential.helper`` for
that host, so a
+ deployment previously relying on its global helper switches to the
connection's token and can
+ start failing auth if that token is stale. On older git the helper is never
configured: the
+ clone then fails with git's generic ``could not read Username ... terminal
prompts disabled``,
+ or, where the deployment configures its own credential helper, authenticates
with that
+ helper's credential instead of the connection's token. Upgrade git on the
Dag processor and on
+ workers before upgrading this provider, or switch the connection to SSH key
authentication.
+
0.5.0
.....
diff --git a/providers/git/docs/connections/git.rst
b/providers/git/docs/connections/git.rst
index 9799bbdd0f1..0246040830e 100644
--- a/providers/git/docs/connections/git.rst
+++ b/providers/git/docs/connections/git.rst
@@ -43,14 +43,18 @@ Repository URL
or ``https://github.com/apache/airflow.git`` for HTTPS.
This can also be passed directly to the hook via the ``repo_url``
parameter.
+ A ``user:password@`` embedded in an ``http(s)`` repository URL is stripped
out before use and
+ treated as the username/access token; the explicit fields below take
precedence over it
+ when both are present.
+
Username or Access Token name (optional)
The username for HTTPS authentication or the token name. Defaults to
``user`` if not specified.
- When using HTTPS with an access token, this value is used as the username
in the
- authenticated URL (e.g. ``https://user:[email protected]/repo.git``).
Access Token (optional)
- The access token for HTTPS authentication. When provided along with the
username,
- the hook injects the credentials into the repository URL for HTTPS cloning.
+ The access token for HTTPS authentication. The connection's username and
token are never written into
+ the repository URL or the bundle's git config; instead they are handed to
git through a
+ credential helper scoped to the repository's host
(``credential.<scheme>://<host>[:port].helper``).
+ Token authentication over http(s) requires git version 2.31 or higher.
Extra (optional)
Specify the extra parameters as a JSON dictionary. The following keys are
supported:
diff --git a/providers/git/src/airflow/providers/git/bundles/git.py
b/providers/git/src/airflow/providers/git/bundles/git.py
index 930331a0b81..69aeecf4daf 100644
--- a/providers/git/src/airflow/providers/git/bundles/git.py
+++ b/providers/git/src/airflow/providers/git/bundles/git.py
@@ -161,6 +161,7 @@ class GitDagBundle(BaseDagBundle):
repo_path=self.repo_path,
version=self.version,
)
+ self._sync_bare_repo_remote_url()
return
if self._local_repo_has_version():
self._log.debug(
@@ -193,6 +194,7 @@ class GitDagBundle(BaseDagBundle):
# HEAD hexsha rather than the raw self.version (which
may be a
# tag or short SHA).
self.repo = repo
+ self._sync_bare_repo_remote_url()
return
cm = self.hook.configure_hook_env() if self.hook else nullcontext()
@@ -304,6 +306,9 @@ class GitDagBundle(BaseDagBundle):
env=self.hook.env if self.hook else None,
)
self.bare_repo = Repo(self.bare_repo_path)
+ # Not the best-effort wrapper: a GitCommandError from the rewrite
must reach the
+ # handler below, which drops the bare repo and re-clones it
credential-free.
+ self._rewrite_bare_repo_origin(self.bare_repo)
# Fetch to ensure we have latest refs and validate repo integrity
self._fetch_bare_repo()
@@ -317,6 +322,56 @@ class GitDagBundle(BaseDagBundle):
shutil.rmtree(self.bare_repo_path)
raise
+ def _sync_bare_repo_remote_url(self) -> None:
+ """
+ Re-point the bare repo's origin at the current repo url.
+
+ Called standalone from the ``_initialize`` fast paths that skip
cloning and never
+ reach ``_clone_bare_repo_if_required``, so a bundle that takes one
would otherwise
+ keep a credentialed origin url in ``bare/config`` forever. This is
best-effort: a
+ bundle that can still be served from disk must not fail to initialize
because its
+ bare repo is unreadable.
+ """
+ try:
+ if not self.bare_repo_path.exists():
+ return
+ bare_repo = Repo(self.bare_repo_path)
+ try:
+ self._rewrite_bare_repo_origin(bare_repo)
+ finally:
+ bare_repo.close()
+ except Exception as e:
+ # Deliberately broad: opening the repo raises anything from
``configparser`` on a
+ # truncated config to ``GitError`` on an unsafe remote url, and
the fast paths this
+ # runs ahead of never needed the bare repo at all.
+ self._log.warning(
+ "Could not rewrite the bare repository origin, a credential
may remain in "
+ "cleartext in the bundle's bare/config",
+ bare_repo_path=self.bare_repo_path,
+ exc=e,
+ )
+
+ @staticmethod
+ def _carries_credentials(url: str) -> bool:
+ """Report whether an origin url has ``user[:password]@`` in its
authority."""
+ if not url.startswith(("http://", "https://")):
+ return False
+ return "@" in url.partition("://")[2].partition("/")[0]
+
+ def _rewrite_bare_repo_origin(self, bare_repo: Repo) -> None:
+ if "origin" not in bare_repo.remotes:
+ return
+ origin = bare_repo.remotes.origin
+ # Bundles cloned before credentials moved to a credential helper
embedded ``user:token``
+ # here, so the token sits in cleartext in ``<bundle>/bare/config``
where any Dag author
+ # on the Dag processor can read it. Rewriting origin is what removes
it from those
+ # bundles. Only an origin holding one is rewritten: git resolves a
local source to an
+ # absolute path when it clones, and replacing that with a relative
repo url would leave
+ # an origin the bare repo cannot resolve.
+ if self._carries_credentials(origin.url):
+ self._log.info("Updating bare repository remote url",
bare_repo_path=self.bare_repo_path)
+ origin.set_url(str(self.repo_url))
+
def _ensure_version_in_bare_repo(self) -> None:
if not self.version:
return
diff --git a/providers/git/src/airflow/providers/git/hooks/git.py
b/providers/git/src/airflow/providers/git/hooks/git.py
index 0f59fde3f2b..4327c2c0ccb 100644
--- a/providers/git/src/airflow/providers/git/hooks/git.py
+++ b/providers/git/src/airflow/providers/git/hooks/git.py
@@ -29,7 +29,7 @@ import warnings
from collections.abc import Generator
from datetime import datetime, timedelta, timezone
from typing import Any
-from urllib.parse import quote as urlquote
+from urllib.parse import unquote
from airflow.exceptions import AirflowProviderDeprecationWarning
from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureException, BaseHook
@@ -59,6 +59,9 @@ class GitHook(BaseHook):
private key to be provided as a PEM-encoded key via either
``private_key`` (inline) or
``key_file`` (path to key file).
* ``github_installation_id`` — GitHub App installation ID used for GitHub
App authentication.
+
+ Token authentication over http(s) configures a credential helper through
``GIT_CONFIG_COUNT``
+ and needs git version 2.31 or higher.
"""
conn_name_attr = "git_conn_id"
@@ -101,8 +104,11 @@ class GitHook(BaseHook):
extra = connection.extra_dejson
self.repo_url = repo_url or connection.host
- self.user_name = connection.login or "user"
- self.auth_token = connection.password
+ if isinstance(self.repo_url, str) and not
self.repo_url.startswith(("git@", "https://")):
+ self.repo_url = os.path.expanduser(self.repo_url)
+ embedded_user, embedded_token = self._strip_embedded_credentials()
+ self.user_name = connection.login or embedded_user or "user"
+ self.auth_token = connection.password or embedded_token
# SSH key authentication
self.private_key = extra.get("private_key")
@@ -155,7 +161,6 @@ class GitHook(BaseHook):
if self.key_file and not self.private_key:
with open(self.key_file, encoding="utf-8") as key_file:
self.private_key = key_file.read()
- self._process_git_auth_url()
_VALID_STRICT_HOST_KEY_CHECKING = frozenset({"yes", "no", "accept-new",
"off", "ask"})
_SSH_REPO_URL_PATTERN = re.compile(r"^[^/@:]+@[^/:]+:")
@@ -242,73 +247,93 @@ class GitHook(BaseHook):
)
self.user_name, self.auth_token, self.github_app_token_exp =
self._get_github_app_token()
+ def _strip_embedded_credentials(self) -> tuple[str | None, str | None]:
+ """Take any ``user:password@`` out of the repo url and return what it
held."""
+ if not isinstance(self.repo_url, str) or not
self.repo_url.startswith(("http://", "https://")):
+ return None, None
+ scheme, separator, rest = self.repo_url.partition("://")
+ authority, slash, path = rest.partition("/")
+ userinfo, at_sign, host = authority.rpartition("@")
+ user, _, password = userinfo.partition(":")
+ # A bare ``user@`` holds no secret, so leave those urls exactly as the
connection wrote
+ # them; anything git clones from a stripped url would lose the
username for nothing.
+ if not at_sign or not password:
+ return None, None
+ self.repo_url = f"{scheme}{separator}{host}{slash}{path}"
+ return unquote(user) or None, unquote(password)
+
+ def _extract_credential_scope(self) -> str:
+ """Return the ``<scheme>://<host>[:port]`` git matches a credential
config against."""
+ scheme, _, rest = str(self.repo_url).partition("://")
+ host = rest.partition("/")[0].rpartition("@")[2]
+ return f"{scheme}://{host}" if host else ""
+
@contextlib.contextmanager
- def _github_app_askpass_env(self) -> Generator[None]:
- if not self.auth_token:
+ def _token_credential_env(self) -> Generator[None]:
+ """Hand the token to git through a credential helper scoped to the
repository's host."""
+ # Credential helpers only serve http(s); an SSH connection that
happens to carry a
+ # password would gain nothing from one.
+ if not self.auth_token or not
str(self.repo_url).startswith(("http://", "https://")):
yield
return
- token = shlex.quote(self.auth_token)
- with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=True)
as askpass_script:
- askpass_script.write(
- "#!/bin/sh\n"
- 'case "$1" in\n'
- " *Username*) echo x-access-token;;\n"
- f" *Password*) echo {token};;\n"
- f" *) echo {token};;\n"
- "esac\n"
- )
- askpass_script.flush()
- os.chmod(askpass_script.name, stat.S_IRWXU)
+ scope = self._extract_credential_scope()
+ if not scope:
+ yield
+ return
+
+ with tempfile.TemporaryDirectory() as helper_dir:
+ helper_path = os.path.join(helper_dir, "credential-helper.sh")
+ # git matches the configured scope against the url it parsed, then
hands the helper
+ # structured fields on stdin. A submodule elsewhere never reaches
this helper, and no
+ # part of the decision depends on the wording of a human-readable
prompt.
+ # Written and closed before git runs: Linux refuses to exec a file
that is still
+ # open for writing, which git surfaces as "cannot exec: Text file
busy".
+ with open(helper_path, "w") as helper_script:
+ helper_script.write(
+ r"""#!/bin/sh
+cat > /dev/null
+[ "$1" = get ] || exit 0
+printf 'username=%s\npassword=%s\n' "$AIRFLOW_GIT_USER" "$AIRFLOW_GIT_TOKEN"
+"""
+ )
+ os.chmod(helper_path, stat.S_IRWXU)
- old_askpass = os.environ.get("GIT_ASKPASS")
- old_lc_all = os.environ.get("LC_ALL")
- old_terminal_prompt = os.environ.get("GIT_TERMINAL_PROMPT")
+ # Append to any GIT_CONFIG_* the deployment already exports rather
than replacing it.
+ try:
+ index = int(os.environ.get("GIT_CONFIG_COUNT", "0"))
+ except ValueError:
+ index = 0
+ # System/global config loads before env-supplied config, so a
deployment-wide
+ # `credential.helper` would otherwise answer `get` first and our
token would never
+ # be used. git also invokes every helper on `approve`, so a
`store` helper would
+ # persist it to ~/.git-credentials. Reset the scope to empty first
(git help credentials).
+ values = {
+ "GIT_CONFIG_COUNT": str(index + 2),
+ f"GIT_CONFIG_KEY_{index}": f"credential.{scope}.helper",
+ f"GIT_CONFIG_VALUE_{index}": "",
+ f"GIT_CONFIG_KEY_{index + 1}": f"credential.{scope}.helper",
+ # git runs the value through a shell, so a temp dir containing
a space would
+ # split into two words. ``!`` marks it as a command so the
quoting survives.
+ f"GIT_CONFIG_VALUE_{index + 1}": "!" +
shlex.quote(helper_path),
+ "GIT_TERMINAL_PROMPT": "0",
+ "AIRFLOW_GIT_USER": self.user_name,
+ "AIRFLOW_GIT_TOKEN": self.auth_token,
+ }
+ # ``self.env`` alone is not enough: callers only forward it on the
initial clone,
+ # so fetches would run without the credential and hang on the
terminal prompt.
+ envs = (os.environ, self.env)
+ saved = [(env, var, env.get(var)) for env in envs for var in
values]
try:
- os.environ["GIT_ASKPASS"] = askpass_script.name
- os.environ["GIT_TERMINAL_PROMPT"] = "0"
- self.env["GIT_ASKPASS"] = askpass_script.name
- self.env["LC_ALL"] = "C"
- self.env["GIT_TERMINAL_PROMPT"] = "0"
+ for env in envs:
+ env.update(values)
yield
finally:
- if old_askpass is None:
- self.env.pop("GIT_ASKPASS", None)
- os.environ.pop("GIT_ASKPASS", None)
- else:
- self.env["GIT_ASKPASS"] = old_askpass
- os.environ["GIT_ASKPASS"] = old_askpass
-
- if old_lc_all is None:
- self.env.pop("LC_ALL", None)
- os.environ.pop("LC_ALL", None)
- else:
- self.env["LC_ALL"] = old_lc_all
- os.environ["LC_ALL"] = old_lc_all
-
- if old_terminal_prompt is None:
- self.env.pop("GIT_TERMINAL_PROMPT", None)
- os.environ.pop("GIT_TERMINAL_PROMPT", None)
- else:
- self.env["GIT_TERMINAL_PROMPT"] = old_terminal_prompt
- os.environ["GIT_TERMINAL_PROMPT"] = old_terminal_prompt
-
- def _process_git_auth_url(self) -> None:
- if not isinstance(self.repo_url, str):
- return
- if self.auth_token and self.repo_url.startswith("https://"):
- encoded_user = urlquote(self.user_name, safe="")
- encoded_token = urlquote(self.auth_token, safe="")
- self.repo_url = self.repo_url.replace("https://",
f"https://{encoded_user}:{encoded_token}@", 1)
- elif self.auth_token and self.repo_url.startswith("http://"):
- encoded_user = urlquote(self.user_name, safe="")
- encoded_token = urlquote(self.auth_token, safe="")
- self.repo_url = self.repo_url.replace("http://",
f"http://{encoded_user}:{encoded_token}@", 1)
- elif self.repo_url.startswith("http://"):
- # if no auth token, use the repo url as is
- pass
- elif not self.repo_url.startswith("git@") and not
self.repo_url.startswith("https://"):
- self.repo_url = os.path.expanduser(self.repo_url)
+ for env, var, old_val in saved:
+ if old_val is None:
+ env.pop(var, None)
+ else:
+ env[var] = old_val
def set_git_env(self, key: str | None = None) -> None:
self.env["GIT_SSH_COMMAND"] = self._build_ssh_command(key)
@@ -352,25 +377,28 @@ class GitHook(BaseHook):
def configure_hook_env(self):
if self.github_app_id is not None and self.github_installation_id is
not None:
self._ensure_github_app_token()
- with self._github_app_askpass_env():
+ with self._token_credential_env():
yield
return
- if self.private_key:
- with tempfile.NamedTemporaryFile(mode="w", delete=True) as
tmp_keyfile:
- tmp_keyfile.write(self.private_key)
- tmp_keyfile.flush()
- os.chmod(tmp_keyfile.name, 0o600)
- self.set_git_env(tmp_keyfile.name)
+ # Wraps every branch, not just the token-only one: an http(s)
connection may also carry
+ # SSH options, and the token used to reach git through the URL
whichever branch ran.
+ with self._token_credential_env():
+ if self.private_key:
+ with tempfile.NamedTemporaryFile(mode="w", delete=True) as
tmp_keyfile:
+ tmp_keyfile.write(self.private_key)
+ tmp_keyfile.flush()
+ os.chmod(tmp_keyfile.name, 0o600)
+ self.set_git_env(tmp_keyfile.name)
+ with self._passphrase_askpass_env():
+ yield
+ elif self.key_file:
+ self.set_git_env(self.key_file)
with self._passphrase_askpass_env():
yield
- elif self.key_file:
- self.set_git_env(self.key_file)
- with self._passphrase_askpass_env():
+ elif self.host_proxy_cmd or self.ssh_port or self.ssh_config_file
or self.known_hosts_file:
+ self.set_git_env()
+ yield
+ else:
+ self.set_git_env(self.key_file)
yield
- elif self.host_proxy_cmd or self.ssh_port or self.ssh_config_file or
self.known_hosts_file:
- self.set_git_env()
- yield
- else:
- self.set_git_env(self.key_file)
- yield
diff --git a/providers/git/tests/unit/git/bundles/test_git.py
b/providers/git/tests/unit/git/bundles/test_git.py
index 0962fa1ad0b..4eac57c1920 100644
--- a/providers/git/tests/unit/git/bundles/test_git.py
+++ b/providers/git/tests/unit/git/bundles/test_git.py
@@ -20,13 +20,14 @@ from __future__ import annotations
import json
import os
import re
+import shlex
import types
from pathlib import Path
from unittest import mock
from unittest.mock import patch
import pytest
-from git import Repo
+from git import RemoteReference, Repo
from git.exc import GitCommandError, InvalidGitRepositoryError, NoSuchPathError
from airflow.dag_processing.bundles.base import get_bundle_storage_root_path
@@ -139,7 +140,7 @@ class TestGitDagBundle:
tracking_ref=GIT_DEFAULT_BRANCH,
repo_url="https://github.com/apache/zzzairflow",
)
- assert bundle.repo_url ==
f"https://user:{ACCESS_TOKEN}@github.com/apache/zzzairflow"
+ assert bundle.repo_url == "https://github.com/apache/zzzairflow"
def test_falls_back_to_connection_host_when_no_repo_url_provided(self):
bundle = GitDagBundle(name="test", git_conn_id=CONN_HTTPS,
tracking_ref=GIT_DEFAULT_BRANCH)
@@ -1431,6 +1432,226 @@ class TestGitDagBundle:
_, kwargs = mock_gitRepo.clone_from.call_args
assert kwargs["env"] == EXPECTED_ENV
+ def test_refresh_propagates_token_credential_env_to_fetch_and_submodules(
+ self, create_connection_without_db
+ ):
+ token = "tok$with'quote"
+ conn_id = "my_git_conn_token_refresh"
+ create_connection_without_db(
+ Connection(
+ conn_id=conn_id,
+ host=AIRFLOW_HTTPS_URL,
+ login="token_user",
+ password=token,
+ conn_type="git",
+ )
+ )
+
+ bundle = GitDagBundle(
+ name="my_repo",
+ git_conn_id=conn_id,
+ tracking_ref=GIT_DEFAULT_BRANCH,
+ submodules=True,
+ )
+
+ bundle.bare_repo = mock.MagicMock(spec=Repo)
+ bundle.repo = mock.MagicMock(spec=Repo)
+
+ origin_ref = mock.MagicMock(spec=RemoteReference)
+ origin_ref.name = f"origin/{GIT_DEFAULT_BRANCH}"
+ bundle.repo.remotes.origin.refs = [origin_ref]
+
+ def _assert_token_env(*_, **__):
+ assert os.environ["AIRFLOW_GIT_TOKEN"] ==
bundle.hook.env["AIRFLOW_GIT_TOKEN"]
+ assert os.environ["GIT_CONFIG_VALUE_0"] ==
bundle.hook.env["GIT_CONFIG_VALUE_0"]
+ assert os.environ["GIT_TERMINAL_PROMPT"] == "0"
+
+ bundle.bare_repo.remotes.origin.fetch.side_effect = _assert_token_env
+ bundle.repo.remotes.origin.fetch.side_effect = _assert_token_env
+ bundle.repo.git.submodule.side_effect = _assert_token_env
+
+ with mock.patch.dict(os.environ, {"GIT_TERMINAL_PROMPT": "1"},
clear=False):
+ bundle.refresh()
+
+ assert os.environ["GIT_TERMINAL_PROMPT"] == "1"
+ assert "AIRFLOW_GIT_TOKEN" not in os.environ
+
+ @mock.patch("airflow.providers.git.bundles.git.GitHook")
+ def
test_bare_repo_remote_url_is_rewritten_when_credentials_are_stored(self,
mock_githook, git_repo):
+ repo_path, _ = git_repo
+ mock_githook.return_value.repo_url = str(repo_path)
+
+ bundle = GitDagBundle(name="test", git_conn_id=CONN_HTTPS,
tracking_ref=GIT_DEFAULT_BRANCH)
+ bundle.initialize()
+
+ bare_config = bundle.bare_repo_path / "config"
+ stale_repo = Repo(bundle.bare_repo_path)
+
stale_repo.remotes.origin.set_url(f"https://user:{ACCESS_TOKEN}@github.com/apache/airflow.git")
+ stale_repo.close()
+ assert ACCESS_TOKEN in bare_config.read_text()
+
+ # A restart on the upgraded version is what has to clean the bundle
up. The fetch is
+ # patched out because an unrewritten remote would reach for the real
host over the network.
+ upgraded = GitDagBundle(name="test", git_conn_id=CONN_HTTPS,
tracking_ref=GIT_DEFAULT_BRANCH)
+ with mock.patch.object(GitDagBundle, "_fetch_bare_repo"):
+ upgraded._clone_bare_repo_if_required()
+
+ assert ACCESS_TOKEN not in bare_config.read_text()
+ assert Repo(bundle.bare_repo_path).remotes.origin.url == str(repo_path)
+
+ @pytest.mark.parametrize("prune_dotgit_folder", [True, False])
+ @mock.patch("airflow.providers.git.bundles.git.GitHook")
+ def
test_version_pinned_bundle_remote_url_is_rewritten_when_credentials_are_stored(
+ self, mock_githook, prune_dotgit_folder, git_repo
+ ):
+ """A version-pinned bundle taking either _initialize() fast path never
reaches
+ _clone_bare_repo_if_required(), but must still have its bare repo's
origin rewritten.
+ """
+ repo_path, repo = git_repo
+ mock_githook.return_value.repo_url = str(repo_path)
+ version = repo.head.commit.hexsha
+ bundle_kwargs = {
+ "name": "test",
+ "git_conn_id": CONN_HTTPS,
+ "version": version,
+ "tracking_ref": GIT_DEFAULT_BRANCH,
+ "prune_dotgit_folder": prune_dotgit_folder,
+ }
+
+ bundle = GitDagBundle(**bundle_kwargs)
+ bundle.initialize()
+
+ bare_config = bundle.bare_repo_path / "config"
+ stale_repo = Repo(bundle.bare_repo_path)
+
stale_repo.remotes.origin.set_url(f"https://user:{ACCESS_TOKEN}@github.com/apache/airflow.git")
+ stale_repo.close()
+ assert ACCESS_TOKEN in bare_config.read_text()
+
+ upgraded = GitDagBundle(**bundle_kwargs)
+ with mock.patch.object(GitDagBundle, "_clone_bare_repo_if_required")
as mock_clone:
+ upgraded.initialize()
+ mock_clone.assert_not_called()
+
+ assert ACCESS_TOKEN not in bare_config.read_text()
+ assert Repo(bundle.bare_repo_path).remotes.origin.url == str(repo_path)
+
+ @pytest.mark.skipif(
+ not AIRFLOW_V_3_1_PLUS, reason="Airflow 3.0 has no structlog caplog to
assert membership on"
+ )
+ @pytest.mark.parametrize("prune_dotgit_folder", [True, False])
+ @pytest.mark.parametrize(
+ "break_bare_repo",
+ [
+ # A git killed mid-write leaves this behind, and every later
config write fails on it.
+ pytest.param(lambda path: (path / "config.lock").touch(),
id="config-locked"),
+ # An unclean shutdown can leave the config zero-filled, which git
cannot parse at all.
+ pytest.param(lambda path: (path / "config").write_bytes(b"\x00" *
64), id="config-corrupt"),
+ ],
+ )
+ @mock.patch("airflow.providers.git.bundles.git.GitHook")
+ def test_initialize_survives_a_bare_repo_that_cannot_be_rewritten(
+ self, mock_githook, break_bare_repo, prune_dotgit_folder, git_repo,
caplog
+ ):
+ """A bundle servable from disk must not fail just because the
credential scrub could not run."""
+ repo_path, repo = git_repo
+ mock_githook.return_value.repo_url = str(repo_path)
+ version = repo.head.commit.hexsha
+ bundle_kwargs = {
+ "name": "test",
+ "git_conn_id": CONN_HTTPS,
+ "version": version,
+ "tracking_ref": GIT_DEFAULT_BRANCH,
+ "prune_dotgit_folder": prune_dotgit_folder,
+ }
+
+ bundle = GitDagBundle(**bundle_kwargs)
+ bundle.initialize()
+ if prune_dotgit_folder:
+ assert bundle._is_pruned_worktree() is True
+ else:
+ assert bundle._local_repo_has_version() is True
+
+ stale_repo = Repo(bundle.bare_repo_path)
+
stale_repo.remotes.origin.set_url(f"https://user:{ACCESS_TOKEN}@github.com/apache/airflow.git")
+ stale_repo.close()
+ break_bare_repo(bundle.bare_repo_path)
+
+ upgraded = GitDagBundle(**bundle_kwargs)
+ upgraded.initialize()
+
+ assert upgraded.path.exists()
+ assert (
+ "Could not rewrite the bare repository origin, a credential may
remain in "
+ "cleartext in the bundle's bare/config"
+ ) in caplog
+
+ @mock.patch("airflow.providers.git.bundles.git.GitHook")
+ def test_relative_local_repo_url_keeps_the_origin_git_resolved(self,
mock_githook, git_repo, monkeypatch):
+ """git records an absolute origin for a local clone; replacing it
breaks every later fetch."""
+ repo_path, _ = git_repo
+ monkeypatch.chdir(repo_path.parent)
+ mock_githook.return_value.repo_url = repo_path.name
+
+ bundle = GitDagBundle(name="test", git_conn_id=CONN_HTTPS,
tracking_ref=GIT_DEFAULT_BRANCH)
+ bundle.initialize()
+
+ assert os.path.isabs(Repo(bundle.bare_repo_path).remotes.origin.url)
+ bundle.refresh()
+
+ @mock.patch("airflow.providers.git.bundles.git.GitHook")
+ def test_clone_path_recovers_when_the_origin_rewrite_fails(self,
mock_githook, git_repo):
+ """On the clone path a failed rewrite must drop the bare repo and
re-clone it.
+
+ ``_fetch_bare_repo`` is patched out so a fetch can never supply the
recovery: without
+ it, the still-credentialed stale origin would be fetched over the real
network before
+ the rewrite failure is even reached, leaving it ambiguous which one
triggered the
+ cleanup-and-retry.
+ """
+ repo_path, _ = git_repo
+ mock_githook.return_value.repo_url = str(repo_path)
+
+ bundle = GitDagBundle(name="test", git_conn_id=CONN_HTTPS,
tracking_ref=GIT_DEFAULT_BRANCH)
+ bundle.initialize()
+
+ bare_config = bundle.bare_repo_path / "config"
+ stale_repo = Repo(bundle.bare_repo_path)
+
stale_repo.remotes.origin.set_url(f"https://user:{ACCESS_TOKEN}@github.com/apache/airflow.git")
+ stale_repo.close()
+ (bundle.bare_repo_path / "config.lock").touch()
+ assert ACCESS_TOKEN in bare_config.read_text()
+
+ upgraded = GitDagBundle(name="test", git_conn_id=CONN_HTTPS,
tracking_ref=GIT_DEFAULT_BRANCH)
+ with mock.patch.object(GitDagBundle, "_fetch_bare_repo"):
+ upgraded._clone_bare_repo_if_required()
+
+ assert ACCESS_TOKEN not in bare_config.read_text()
+
+ @mock.patch("airflow.providers.git.bundles.git.Repo")
+ def test_clone_passes_token_credential_env_to_gitpython(self,
mock_gitRepo, create_connection_without_db):
+ conn_id = "my_git_conn_token_clone"
+ create_connection_without_db(
+ Connection(
+ conn_id=conn_id,
+ host=AIRFLOW_HTTPS_URL,
+ login="token_user",
+ password=ACCESS_TOKEN,
+ conn_type="git",
+ )
+ )
+
+ bundle = GitDagBundle(name="my_repo", git_conn_id=conn_id,
tracking_ref=GIT_DEFAULT_BRANCH)
+
+ with bundle.hook.configure_hook_env():
+ bundle._clone_bare_repo_if_required()
+ _, kwargs = mock_gitRepo.clone_from.call_args
+ assert kwargs["env"]["GIT_CONFIG_VALUE_0"] == ""
+ helper_path =
shlex.split(kwargs["env"]["GIT_CONFIG_VALUE_1"][1:])[0]
+ assert kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0"
+ assert kwargs["env"]["AIRFLOW_GIT_TOKEN"] == ACCESS_TOKEN
+ assert os.path.exists(helper_path)
+
+ assert not os.path.exists(helper_path)
+
@mock.patch("airflow.providers.git.bundles.git.GitHook")
@mock.patch("airflow.providers.git.bundles.git.shutil.rmtree")
@mock.patch("airflow.providers.git.bundles.git.os.path.exists")
diff --git a/providers/git/tests/unit/git/hooks/test_git.py
b/providers/git/tests/unit/git/hooks/test_git.py
index 30b8717dd70..e14a7672850 100644
--- a/providers/git/tests/unit/git/hooks/test_git.py
+++ b/providers/git/tests/unit/git/hooks/test_git.py
@@ -17,9 +17,18 @@
from __future__ import annotations
+import base64
import contextlib
+import http.server
import os
+import pathlib
+import shlex
+import socketserver
+import subprocess
+import tempfile
+import threading
import warnings
+from unittest import mock
import pytest
from git import Repo
@@ -59,6 +68,49 @@ CONN_APP_INVALID_APP_ID = "git_app_invalid_app_id"
CONN_APP_INVALID_INSTALLATION_ID = "git_app_invalid_installation_id"
[email protected]
+def recording_git_server():
+ """Serve 401s on loopback, recording every credential git sends."""
+ received: list[str] = []
+
+ class Unauthorized(http.server.BaseHTTPRequestHandler):
+ def do_GET(self):
+ header = self.headers.get("Authorization")
+ if header and header.startswith("Basic "):
+ received.append(base64.b64decode(header[6:]).decode())
+ self.send_response(401)
+ self.send_header("WWW-Authenticate", 'Basic realm="git"')
+ self.send_header("Content-Length", "0")
+ self.end_headers()
+
+ def log_message(self, *args):
+ pass
+
+ server = socketserver.TCPServer(("127.0.0.1", 0), Unauthorized)
+ threading.Thread(target=server.serve_forever, daemon=True).start()
+ try:
+ yield server.server_address[1], received
+ finally:
+ server.shutdown()
+ server.server_close()
+
+
+def helper_path_from(config_value: str) -> str:
+ """Undo the ``!<quoted path>`` form git needs, the way a shell would."""
+ assert config_value.startswith("!")
+ return shlex.split(config_value[1:])[0]
+
+
+def git_ls_remote(url: str, env: dict[str, str]) -> None:
+ subprocess.run(
+ ["git", "ls-remote", url],
+ capture_output=True,
+ text=True,
+ check=False,
+ env={**os.environ, **env},
+ )
+
+
@pytest.fixture
def git_repo(tmp_path_factory):
directory = tmp_path_factory.mktemp("repo")
@@ -212,11 +264,11 @@ class TestGitHook:
("conn_id", "hook_kwargs", "expected_repo_url", "warns_on_default"),
[
(CONN_DEFAULT, {}, AIRFLOW_GIT, True),
- (CONN_HTTPS, {},
f"https://user:{ACCESS_TOKEN}@github.com/apache/airflow.git", False),
+ (CONN_HTTPS, {}, AIRFLOW_HTTPS_URL, False),
(
CONN_HTTPS,
{"repo_url": "https://github.com/apache/zzzairflow"},
- f"https://user:{ACCESS_TOKEN}@github.com/apache/zzzairflow",
+ "https://github.com/apache/zzzairflow",
False,
),
(
@@ -225,11 +277,11 @@ class TestGitHook:
AIRFLOW_GIT,
True,
),
- (CONN_HTTP, {},
f"http://user:{ACCESS_TOKEN}@github.com/apache/airflow.git", False),
+ (CONN_HTTP, {}, AIRFLOW_HTTP_URL, False),
(
CONN_HTTP,
{"repo_url": "http://github.com/apache/zzzairflow"},
- f"http://user:{ACCESS_TOKEN}@github.com/apache/zzzairflow",
+ "http://github.com/apache/zzzairflow",
False,
),
(CONN_HTTP_NO_AUTH, {}, AIRFLOW_HTTP_URL, False),
@@ -252,6 +304,18 @@ class TestGitHook:
hook = GitHook(git_conn_id=conn_id, **hook_kwargs)
assert hook.repo_url == expected_repo_url
+ def test_repo_url_is_expanded_during_init(self,
create_connection_without_db):
+ create_connection_without_db(
+ Connection(
+ conn_id="git_tilde_repo",
+ host="~/repo.git",
+ conn_type="git",
+ )
+ )
+
+ hook = GitHook(git_conn_id="git_tilde_repo")
+ assert hook.repo_url == os.path.expanduser("~/repo.git")
+
def test_env_var_with_configure_hook_env(self,
create_connection_without_db):
with pytest.warns(AirflowProviderDeprecationWarning,
match="accept-new"):
default_hook = GitHook(git_conn_id=CONN_DEFAULT)
@@ -499,6 +563,223 @@ class TestGitHook:
# Both the askpass script and the temp key file should be cleaned up
assert not os.path.exists(askpass_path)
+ @pytest.mark.parametrize(
+ ("host", "expected_url", "expected_user", "expected_token"),
+ [
+ pytest.param(
+
"https://airflow_cen:[email protected]/pibi/dags.git",
+ "https://gitlab.example.com/pibi/dags.git",
+ "airflow_cen",
+ "CLEARTEXT_TOKEN",
+ id="user-and-password",
+ ),
+ pytest.param(
+
"https://airflow_cen:tok%40en%[email protected]/pibi/dags.git",
+ "https://gitlab.example.com/pibi/dags.git",
+ "airflow_cen",
+ "tok@en/1",
+ id="percent-encoded-password",
+ ),
+ pytest.param(
+ "https://gitlab.example.com/pibi/dags.git",
+ "https://gitlab.example.com/pibi/dags.git",
+ "user",
+ None,
+ id="no-credentials",
+ ),
+ pytest.param(
+ "https://[email protected]/pibi/dags.git",
+ "https://[email protected]/pibi/dags.git",
+ "user",
+ None,
+ id="username-only-is-left-alone",
+ ),
+ pytest.param(
+ "https://gitlab.example.com/pibi/a@b/dags.git",
+ "https://gitlab.example.com/pibi/a@b/dags.git",
+ "user",
+ None,
+ id="at-sign-in-path",
+ ),
+ ],
+ )
+ def test_credentials_embedded_in_the_host_do_not_stay_in_the_url(
+ self, host, expected_url, expected_user, expected_token,
create_connection_without_db
+ ):
+ create_connection_without_db(
+ Connection(conn_id="git_embedded_credentials", host=host,
conn_type="git")
+ )
+
+ hook = GitHook(git_conn_id="git_embedded_credentials")
+
+ assert hook.repo_url == expected_url
+ assert hook.user_name == expected_user
+ assert hook.auth_token == expected_token
+
+ def test_connection_fields_win_over_credentials_embedded_in_the_host(self,
create_connection_without_db):
+ create_connection_without_db(
+ Connection(
+ conn_id="git_embedded_and_explicit",
+
host="https://embedded_user:[email protected]/pibi/dags.git",
+ login="explicit_user",
+ password="explicit_token",
+ conn_type="git",
+ )
+ )
+
+ hook = GitHook(git_conn_id="git_embedded_and_explicit")
+
+ assert hook.repo_url == "https://gitlab.example.com/pibi/dags.git"
+ assert hook.user_name == "explicit_user"
+ assert hook.auth_token == "explicit_token"
+
+ def test_token_credential_env_and_cleanup(self,
create_connection_without_db):
+ token = "tok$with'quote"
+ create_connection_without_db(
+ Connection(
+ conn_id="git_token_credential",
+ host=AIRFLOW_HTTPS_URL,
+ password=token,
+ conn_type="git",
+ )
+ )
+ hook = GitHook(git_conn_id="git_token_credential")
+ helper_path = None
+
+ with mock.patch.dict(os.environ, {"GIT_TERMINAL_PROMPT": "1"},
clear=False):
+ with hook.configure_hook_env():
+ assert hook.env["GIT_CONFIG_COUNT"] == "2"
+ # Entry order is load-bearing: reset (index 0) must precede
the helper (index 1).
+ assert hook.env["GIT_CONFIG_KEY_0"] ==
"credential.https://github.com.helper"
+ assert hook.env["GIT_CONFIG_VALUE_0"] == ""
+ assert hook.env["GIT_CONFIG_KEY_1"] ==
"credential.https://github.com.helper"
+ assert hook.env["GIT_TERMINAL_PROMPT"] == "0"
+ helper_path = helper_path_from(hook.env["GIT_CONFIG_VALUE_1"])
+ assert os.path.exists(helper_path)
+
+ # The credential is passed in the environment, never written
to the script
+ assert token not in pathlib.Path(helper_path).read_text()
+ assert os.environ["AIRFLOW_GIT_TOKEN"] == token
+
+ assert os.environ["GIT_TERMINAL_PROMPT"] == "1"
+ assert "AIRFLOW_GIT_TOKEN" not in os.environ
+ assert "GIT_CONFIG_COUNT" not in hook.env
+
+ assert not os.path.exists(helper_path)
+
+ def test_token_credential_uses_connection_login(self,
create_connection_without_db):
+ username = "token_user"
+ create_connection_without_db(
+ Connection(
+ conn_id="my_git_conn_https_with_login",
+ host=AIRFLOW_HTTPS_URL,
+ login=username,
+ password=ACCESS_TOKEN,
+ conn_type="git",
+ )
+ )
+ hook = GitHook(git_conn_id="my_git_conn_https_with_login")
+
+ with hook.configure_hook_env():
+ assert hook.env["AIRFLOW_GIT_USER"] == username
+ assert hook.env["AIRFLOW_GIT_TOKEN"] == ACCESS_TOKEN
+
+ @pytest.mark.parametrize(
+ "extra",
+ [
+ pytest.param({"key_file": "/files/pkey.pem"}, id="key_file"),
+ pytest.param({"private_key": "inline_key"}, id="private_key"),
+ pytest.param({"known_hosts_file": "/files/known_hosts"},
id="known_hosts_file"),
+ pytest.param({"ssh_config_file": "/files/ssh_config"},
id="ssh_config_file"),
+ pytest.param({"host_proxy_cmd": "nc %h %p"}, id="host_proxy_cmd"),
+ pytest.param({"ssh_port": "2222"}, id="ssh_port"),
+ ],
+ )
+ def test_token_credential_env_is_set_alongside_ssh_options(self, extra,
create_connection_without_db):
+ create_connection_without_db(
+ Connection(
+ conn_id="git_token_with_ssh_options",
+ host=AIRFLOW_HTTPS_URL,
+ password=ACCESS_TOKEN,
+ conn_type="git",
+ extra={"strict_host_key_checking": "accept-new", **extra},
+ )
+ )
+ hook = GitHook(git_conn_id="git_token_with_ssh_options")
+
+ with hook.configure_hook_env():
+ assert hook.env["AIRFLOW_GIT_TOKEN"] == ACCESS_TOKEN
+ assert hook.env["GIT_TERMINAL_PROMPT"] == "0"
+
+ def test_credential_helper_answers_only_the_repository_host(self,
create_connection_without_db):
+ """A submodule url that impersonates the repo host in its username
gets nothing.
+
+ git matches the configured credential scope against the url it parsed,
so
+ ``http://<repo host>'@evil/x.git`` — whose host is evil — never
reaches the helper.
+ """
+ with recording_git_server() as (repo_port, repo_received):
+ with recording_git_server() as (other_port, other_received):
+ create_connection_without_db(
+ Connection(
+ conn_id="git_token_credential_scope",
+ host=f"http://127.0.0.1:{repo_port}/repo.git",
+ login="token_user",
+ password=ACCESS_TOKEN,
+ conn_type="git",
+ )
+ )
+ hook = GitHook(git_conn_id="git_token_credential_scope")
+
+ with hook.configure_hook_env():
+ git_ls_remote(f"http://127.0.0.1:{repo_port}/repo.git",
hook.env)
+
git_ls_remote(f"http://127.0.0.1:{repo_port}'@127.0.0.1:{other_port}/x.git",
hook.env)
+ git_ls_remote(f"http://127.0.0.1:{other_port}/x.git",
hook.env)
+
+ assert repo_received == [f"token_user:{ACCESS_TOKEN}"]
+ assert ACCESS_TOKEN not in "".join(other_received)
+
+ def test_credential_helper_survives_a_temp_dir_containing_spaces(
+ self, create_connection_without_db, monkeypatch, tmp_path
+ ):
+ """git runs the helper value through a shell, so an unquoted path
would split on space."""
+ spaced_tmp = tmp_path / "tmp dir with spaces"
+ spaced_tmp.mkdir()
+ # gettempdir() caches its answer, so TMPDIR alone would not be read by
this point
+ monkeypatch.setattr(tempfile, "tempdir", str(spaced_tmp))
+
+ with recording_git_server() as (port, received):
+ create_connection_without_db(
+ Connection(
+ conn_id="git_spaced_tmpdir",
+ host=f"http://127.0.0.1:{port}/repo.git",
+ login="token_user",
+ password=ACCESS_TOKEN,
+ conn_type="git",
+ )
+ )
+ hook = GitHook(git_conn_id="git_spaced_tmpdir")
+
+ with hook.configure_hook_env():
+ git_ls_remote(f"http://127.0.0.1:{port}/repo.git", hook.env)
+
+ assert received == [f"token_user:{ACCESS_TOKEN}"]
+
+ def test_token_credential_env_skipped_for_ssh_transport(self,
create_connection_without_db):
+ create_connection_without_db(
+ Connection(
+ conn_id="git_ssh_with_password",
+ host=AIRFLOW_GIT,
+ password=ACCESS_TOKEN,
+ conn_type="git",
+ extra={"key_file": "/files/pkey.pem",
"strict_host_key_checking": "accept-new"},
+ )
+ )
+ hook = GitHook(git_conn_id="git_ssh_with_password")
+
+ with hook.configure_hook_env():
+ assert "GIT_CONFIG_COUNT" not in hook.env
+ assert "AIRFLOW_GIT_TOKEN" not in hook.env
+
# --- GitHub App auth tests ---
def test_only_app_id_without_installation_id_raises(self):
@@ -633,6 +914,34 @@ class TestGitHook:
assert hook.github_app_id == app_id
assert hook.github_installation_id == installation_id
+ def test_github_app_token_is_scoped_to_the_repository_host(self,
monkeypatch):
+ """The installation token goes through the same host-scoped helper as
a connection token."""
+ from datetime import datetime, timedelta, timezone
+
+ monkeypatch.setattr(
+ "airflow.providers.git.hooks.git.GitHook._get_github_app_token",
+ lambda self: (
+ "x-access-token",
+ "ghs_installation_token",
+ datetime.now(timezone.utc) + timedelta(hours=1),
+ ),
+ )
+ with pytest.warns(AirflowProviderDeprecationWarning,
match="accept-new"):
+ hook = GitHook(git_conn_id=CONN_APP_INLINE_KEY)
+
+ with hook.configure_hook_env():
+ assert hook.env["GIT_CONFIG_KEY_0"] ==
"credential.https://github.com.helper"
+ assert hook.env["GIT_CONFIG_VALUE_0"] == ""
+ assert hook.env["GIT_CONFIG_KEY_1"] ==
"credential.https://github.com.helper"
+ assert hook.env["AIRFLOW_GIT_USER"] == "x-access-token"
+ assert hook.env["AIRFLOW_GIT_TOKEN"] == "ghs_installation_token"
+ # Nothing sensitive reaches the script, and no prompt-matching
remains
+ helper_path = helper_path_from(hook.env["GIT_CONFIG_VALUE_1"])
+ assert "ghs_installation_token" not in
pathlib.Path(helper_path).read_text()
+ assert "GIT_ASKPASS" not in hook.env
+
+ assert "AIRFLOW_GIT_TOKEN" not in os.environ
+
def test_github_app_token_refresh_near_expiry(self, monkeypatch):
"""Token is refreshed when near expiry during configure_hook_env."""
from datetime import datetime, timedelta, timezone