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 eeab7a877df Redact Connection extra fields that providers declare as
secrets (#73797)
eeab7a877df is described below
commit eeab7a877df75b5132702821abf380e10fb87fc6
Author: JelyFishhhhhh <[email protected]>
AuthorDate: Tue Nov 10 16:38:02 2026 +0800
Redact Connection extra fields that providers declare as secrets (#73797)
Providers declare these connection extra fields as secrets, but their names
were missing from the redaction allowlist, so their values were not redacted
like other secret fields. They are matched as whole names because, as
substrings, they would also hide non-secret fields such as kube_config_path
and credential_config_file.
Co-authored-by: JelyF1shhhhhh <[email protected]>
---
.../tests/unit/always/test_providers_manager.py | 32 ++++++++++++++++++++++
.../secrets_masker/secrets_masker.py | 22 +++++++++++++++
.../tests/secrets_masker/test_secrets_masker.py | 29 ++++++++++++++++++++
3 files changed, 83 insertions(+)
diff --git a/airflow-core/tests/unit/always/test_providers_manager.py
b/airflow-core/tests/unit/always/test_providers_manager.py
index 424a629ba1f..b82ff4c6228 100644
--- a/airflow-core/tests/unit/always/test_providers_manager.py
+++ b/airflow-core/tests/unit/always/test_providers_manager.py
@@ -36,6 +36,7 @@ from unittest.mock import Mock, patch
import pytest
+from airflow._shared.secrets_masker import DEFAULT_SENSITIVE_FIELDS,
SecretsMasker
from airflow.providers_manager import (
DialectInfo,
LazyDictWithCache,
@@ -269,6 +270,37 @@ class TestProviderManager:
# widgets loaded from YAML metadata only
assert len(connections_form_widgets) == yaml_widgets
+ def test_sensitive_connection_fields_are_redactable(self):
+ """
+ Every connection field a provider declares as sensitive must be in the
redaction allowlist.
+
+ Providers already state which fields are secret; this asserts the
allowlist never drifts
+ apart from them.
+ """
+ # Check the shipped defaults rather than the process-wide masker, whose
+ # sensitive_variables_fields is only populated once settings have been
configured.
+ masker = SecretsMasker()
+ masker.sensitive_variables_fields = list(DEFAULT_SENSITIVE_FIELDS)
+
+ # "config" is too generic a field name for the allowlist. apprise's
"config" is masked in
+ # API responses because its provider declares it sensitive, but it is
not masked in task logs.
+ allowed_unredacted = {("config", "apache-airflow-providers-apprise")}
+
+ provider_manager = ProvidersManager()
+ missing = sorted(
+ {
+ (widget.field_name, widget.package_name)
+ for widget in
provider_manager._connection_form_widgets_from_metadata.values()
+ if widget.is_sensitive and not
masker.should_hide_value_for_key(widget.field_name)
+ }
+ - allowed_unredacted
+ )
+ assert not missing, (
+ "These connection fields are declared sensitive by their provider
but are not "
+ "redacted. Add each name to DEFAULT_SENSITIVE_FIELD_NAMES in "
+ f"shared/secrets_masker: {missing}"
+ )
+
def test_field_behaviours(self, yaml_ui_metadata_counts):
_, yaml_behaviours = yaml_ui_metadata_counts
provider_manager = ProvidersManager()
diff --git
a/shared/secrets_masker/src/airflow_shared/secrets_masker/secrets_masker.py
b/shared/secrets_masker/src/airflow_shared/secrets_masker/secrets_masker.py
index 3003eefe2e5..d34d9b0a263 100644
--- a/shared/secrets_masker/src/airflow_shared/secrets_masker/secrets_masker.py
+++ b/shared/secrets_masker/src/airflow_shared/secrets_masker/secrets_masker.py
@@ -76,6 +76,26 @@ DEFAULT_SENSITIVE_FIELDS = frozenset(
)
"""Names of fields (Connection extra, Variable key name etc.) that are deemed
sensitive"""
+DEFAULT_SENSITIVE_FIELD_NAMES = frozenset(
+ {
+ "app_key",
+ "certificate",
+ "credential",
+ "keytab",
+ "kube_config",
+ "oauth",
+ "routing_key",
+ "thumbprint",
+ "write_key",
+ }
+)
+"""
+Names of fields (Connection extra, Variable key name etc.) that are sensitive
only as a whole name.
+
+Matching these as substrings would also hide non-secret fields such as
``kube_config_path`` or
+``credential_config_file``. The ``extra__<conn_type>__<name>`` form of each
name matches too.
+"""
+
SECRETS_TO_SKIP_MASKING = {"airflow"}
"""Common terms that should be excluded from masking in both production and
tests"""
@@ -592,6 +612,8 @@ class SecretsMasker(logging.Filter):
normalized = _NON_WORD_RE.sub("_", name.lower())
split = _CAMEL_ACRONYM_RE.sub(r"\1_\2",
_CAMEL_LOWER_UPPER_RE.sub(r"\1_\2", name))
split = _NON_WORD_RE.sub("_", split.lower())
+ if any(form.rsplit("__", 1)[-1] in DEFAULT_SENSITIVE_FIELD_NAMES
for form in (normalized, split)):
+ return True
# Check both forms so fields configured without separators (e.g.
``mysensitivekey``)
# keep matching camelCase keys such as ``mySensitiveKey``.
return any(s in normalized or s in split for s in
self.sensitive_variables_fields)
diff --git a/shared/secrets_masker/tests/secrets_masker/test_secrets_masker.py
b/shared/secrets_masker/tests/secrets_masker/test_secrets_masker.py
index 9f79284b642..1d692e53faa 100644
--- a/shared/secrets_masker/tests/secrets_masker/test_secrets_masker.py
+++ b/shared/secrets_masker/tests/secrets_masker/test_secrets_masker.py
@@ -904,6 +904,35 @@ class TestShouldHideValueForKey:
configure_secrets_masker_for_test(masker)
assert expected_result == masker.should_hide_value_for_key(key)
+ @pytest.mark.parametrize(
+ ("key", "expected_result"),
+ [
+ ("app_key", True),
+ ("certificate", True),
+ ("credential", True),
+ ("keytab", True),
+ ("kube_config", True),
+ ("KUBE_CONFIG", True),
+ ("oauth", True),
+ ("routing_key", True),
+ ("thumbprint", True),
+ ("write_key", True),
+ ("extra__kubernetes__kube_config", True),
+ ("kubeConfig", True),
+ ("routingKey", True),
+ ("kube_config_path", False),
+ ("kubeConfigPath", False),
+ ("extra__kubernetes__kube_config_path", False),
+ ("credential_config_file", False),
+ ("certificate_path", False),
+ ("service_principal_oauth", False),
+ ],
+ )
+ def test_hiding_whole_field_names(self, key, expected_result):
+ masker = SecretsMasker()
+ configure_secrets_masker_for_test(masker)
+ assert expected_result == masker.should_hide_value_for_key(key)
+
@pytest.mark.parametrize(
("sensitive_variable_fields", "key", "expected_result"),
[