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 5444dabe85f Redact non-string values when the key name is sensitive
(#71279)
5444dabe85f is described below
commit 5444dabe85f48cb16fa77444181b3f71929ba71a
Author: Shashwati Bhattacharyaa <[email protected]>
AuthorDate: Mon Nov 2 05:45:03 2026 +0530
Redact non-string values when the key name is sensitive (#71279)
Once a key name is judged sensitive, _redact_all replaced only str values
and returned every other scalar unchanged. The variables API json.loads() each
value first, so an all-digit PIN or API key under a *_password key came back in
the clear while an alphanumeric one was masked.
closes: #71275
Generated-by: Claude Opus 5
Co-authored-by: Shashwati <[email protected]>
---
.../secrets_masker/secrets_masker.py | 7 ++-
.../tests/secrets_masker/test_secrets_masker.py | 68 +++++++++++++++++++++-
2 files changed, 71 insertions(+), 4 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 c2d2a744d59..e7aec72a8d7 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
@@ -341,7 +341,9 @@ class SecretsMasker(logging.Filter):
*,
replacement: str = "***",
) -> Redacted:
- if depth > max_depth or isinstance(item, str):
+ # The key is sensitive, so every leaf is replaced whatever its type: a
numeric secret
+ # (e.g. a PIN that json.loads() turned into an int) must not come back
in the clear.
+ if depth > max_depth or not isinstance(item, (dict, tuple, set, list)):
return replacement
if isinstance(item, dict):
return {
@@ -357,7 +359,8 @@ class SecretsMasker(logging.Filter):
return list(
self._redact_all(subval, depth + 1, max_depth,
replacement=replacement) for subval in item
)
- return item
+ # Fail closed if a container type is added to the walk above without a
branch here.
+ return replacement
def _redact(
self, item: Redactable, name: str | None, depth: int, max_depth: int,
replacement: str = "***"
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 01d9142da1a..136e51d43d7 100644
--- a/shared/secrets_masker/tests/secrets_masker/test_secrets_masker.py
+++ b/shared/secrets_masker/tests/secrets_masker/test_secrets_masker.py
@@ -1256,8 +1256,10 @@ class TestDirectMethodCalls:
result = secrets_masker._redact_all(test_data, depth=0,
replacement="***")
assert result["string"] == "***"
- assert result["number"] == 12345
- assert result["boolean"] is True
+ # Non-string scalars are redacted too -- _redact_all is the fail-closed
+ # path taken once a key name is judged sensitive.
+ assert result["number"] == "***"
+ assert result["boolean"] == "***"
assert all(val == "***" for val in result["list"])
assert all(val == "***" for val in result["dict"].values())
assert all(val == "***" for val in result["nested"]["tuple"])
@@ -1265,6 +1267,68 @@ class TestDirectMethodCalls:
assert all(val == "***" for val in result["nested"]["set"])
+class TestNonStringRedactionForSensitiveKeys:
+ """A sensitive key name must redact its value whatever the value's type."""
+
+ @pytest.fixture
+ def masker(self):
+ secrets_masker = SecretsMasker()
+ configure_secrets_masker_for_test(secrets_masker)
+ return secrets_masker
+
+ @pytest.mark.parametrize(
+ "value",
+ [
+ pytest.param(1234, id="int"),
+ pytest.param(1234.5, id="float"),
+ pytest.param(True, id="bool-true"),
+ pytest.param(False, id="bool-false"),
+ pytest.param(0, id="int-zero"),
+ pytest.param(None, id="none"),
+ pytest.param(b"binary-secret", id="bytes"),
+ pytest.param("12ab", id="str"),
+ ],
+ )
+ def test_scalar_redacted_for_sensitive_key(self, masker, value):
+ """Values of any type are masked once the key name is sensitive."""
+ assert masker.redact(value, "test-password-a") == "***"
+
+ def test_non_sensitive_key_leaves_scalars_alone(self, masker):
+ """The fix must not start masking values under harmless key names."""
+ assert masker.redact(1234, "some-count") == 1234
+ assert masker.redact(True, "some-flag") is True
+ assert masker.redact(None, "some-optional") is None
+
+ def test_nested_non_string_values_are_redacted(self, masker):
+ """Containers under a sensitive key are walked and every leaf
replaced."""
+ result = masker.redact({"pin": 1234, "nested": {"attempts": 3, "ok":
False}}, "my_token")
+
+ assert result == {"pin": "***", "nested": {"attempts": "***", "ok":
"***"}}
+
+ def test_non_string_under_nested_sensitive_key(self, masker):
+ """A sensitive key nested inside a non-sensitive structure is still
caught."""
+ result = masker.redact({"conn": {"password": 12345, "port": 5432}})
+
+ assert result == {"conn": {"password": "***", "port": 5432}}
+
+ def test_merge_restores_original_non_string_value(self, masker):
+ """
+ Redacting non-strings must not break the merge() round trip.
+
+ merge() restores the original whenever the redacted value is still the
+ replacement sentinel, so an untouched numeric secret survives a
+ read-modify-write cycle unchanged.
+ """
+ original = 1234
+ redacted = masker.redact(original, "test-password-b")
+ assert redacted == "***"
+ assert masker.merge(redacted, original, "test-password-b") == original
+
+ def test_merge_keeps_edited_non_string_value(self, masker):
+ """A genuinely updated value still wins over the original."""
+ assert masker.merge(5678, 1234, "test-password-b") == 5678
+
+
class TestMixedDataScenarios:
def test_mixed_structured_unstructured_data(self):
secrets_masker = SecretsMasker()