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()

Reply via email to