This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch v3-2-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-2-test by this push:
new b5f0ec62092 [v3-2-test] Check sensitive key names before applying
recursion-depth cutoff in secrets masker (#65912) (#66748)
b5f0ec62092 is described below
commit b5f0ec62092d1ba5c5a93a510d85d0a419aac7f2
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed May 13 20:50:01 2026 +0200
[v3-2-test] Check sensitive key names before applying recursion-depth
cutoff in secrets masker (#65912) (#66748)
`SecretsMasker._redact` short-circuited on `depth > max_depth` before
checking whether the current key name was sensitive
(`should_hide_value_for_key(name)`). For sensitive keys nested beyond
the recursion depth (default 5), the original value was returned
unchanged instead of being replaced with `***`.
Move the depth cutoff inside the `try:` block, after the
sensitive-key check, and let dict traversal continue past the cutoff
so deeper sensitive keys are still caught. Non-dict containers and
the string-pattern masker keep the depth-bounded behavior the cutoff
was added for. JSON-loaded payloads cannot be self-referential, and
any in-memory cycle hits Python's own recursion limit and falls
through the existing exception handler to "<redaction-failed>",
which preserves the fail-closed property.
(cherry picked from commit 354391bbccc1658ce66d8ec2e2e415e6a01aa7a4)
Generated-by: Claude Opus 4.7 (1M context) following the guidelines at
https:
//github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions
Co-authored-by: Jarek Potiuk <[email protected]>
---
.../secrets_masker/secrets_masker.py | 18 +++++++---
.../tests/secrets_masker/test_secrets_masker.py | 38 ++++++++++++++++++++++
2 files changed, 51 insertions(+), 5 deletions(-)
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 fb5e10302d7..1fc046de583 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
@@ -344,14 +344,18 @@ class SecretsMasker(logging.Filter):
def _redact(
self, item: Redactable, name: str | None, depth: int, max_depth: int,
replacement: str = "***"
) -> Redacted:
- # Avoid spending too much effort on redacting on deeply nested
- # structures. This also avoid infinite recursion if a structure has
- # reference to self.
- if depth > max_depth:
- return item
try:
+ # Key-name-based redaction is unbounded by depth — sensitive keys
+ # must fail closed at any nesting level. The depth cutoff below is
+ # only used to bound the work of pattern-based string masking and
+ # to terminate recursion through self-referential iterables.
if name and self.should_hide_value_for_key(name):
return self._redact_all(item, depth, max_depth,
replacement=replacement)
+ # Always walk dicts so deeper sensitive keys are still caught;
+ # JSON-loaded payloads cannot be self-referential, and any
+ # in-memory cycle hits Python's own recursion limit and is caught
+ # by the except clause below (which fails closed via
+ # "<redaction-failed>").
if isinstance(item, dict):
to_return = {
dict_key: self._redact(
@@ -360,6 +364,10 @@ class SecretsMasker(logging.Filter):
for dict_key, subval in item.items()
}
return to_return
+ # Avoid spending too much effort on pattern-based masking of
+ # deeply nested non-dict structures.
+ if depth > max_depth:
+ return item
if isinstance(item, Enum):
return self._redact(
item=item.value, name=name, depth=depth,
max_depth=max_depth, replacement=replacement
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 e23cb1c2744..930748e2ed0 100644
--- a/shared/secrets_masker/tests/secrets_masker/test_secrets_masker.py
+++ b/shared/secrets_masker/tests/secrets_masker/test_secrets_masker.py
@@ -690,6 +690,44 @@ class TestSecretsMasker:
got = redact(val, max_depth=max_depth)
assert got == expected
+ @pytest.mark.parametrize(
+ ("val", "expected"),
+ [
+ # Sensitive key at exactly MAX_RECURSION_DEPTH (5) is redacted.
+ (
+ {"a": {"b": {"c": {"d": {"password": "leaked"}}}}},
+ {"a": {"b": {"c": {"d": {"password": "***"}}}}},
+ ),
+ # Sensitive key one level past MAX_RECURSION_DEPTH is also
redacted.
+ (
+ {"a": {"b": {"c": {"d": {"e": {"password": "leaked"}}}}}},
+ {"a": {"b": {"c": {"d": {"e": {"password": "***"}}}}}},
+ ),
+ # Two levels past MAX_RECURSION_DEPTH, under a non-sensitive
+ # intermediate key, still fails closed.
+ (
+ {"a": {"b": {"c": {"d": {"e": {"f": {"token": "leaked"}}}}}}},
+ {"a": {"b": {"c": {"d": {"e": {"f": {"token": "***"}}}}}}},
+ ),
+ # Other sensitive key names recognised by
should_hide_value_for_key.
+ (
+ {"a": {"b": {"c": {"d": {"e": {"secret": "leaked"}}}}}},
+ {"a": {"b": {"c": {"d": {"e": {"secret": "***"}}}}}},
+ ),
+ (
+ {"a": {"b": {"c": {"d": {"e": {"api_key": "leaked"}}}}}},
+ {"a": {"b": {"c": {"d": {"e": {"api_key": "***"}}}}}},
+ ),
+ ],
+ )
+ def test_redact_sensitive_key_past_max_depth(self, val, expected):
+ secrets_masker = SecretsMasker()
+ configure_secrets_masker_for_test(secrets_masker)
+ with patch(
+ "airflow_shared.secrets_masker.secrets_masker._secrets_masker",
return_value=secrets_masker
+ ):
+ assert redact(val) == expected
+
def test_redact_with_str_type(self, logger, caplog):
"""
SecretsMasker's re replacer has issues handling a redactable item of
type