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 cd28fbde0be Fix secrets not masked in Rendered Templates view with 
KubernetesPodOperator (#68975)
cd28fbde0be is described below

commit cd28fbde0bef19837d2e85d1de256c7bc1d7d370
Author: juan-pablo-guereca <[email protected]>
AuthorDate: Wed Jul 22 21:01:52 2026 +0200

    Fix secrets not masked in Rendered Templates view with 
KubernetesPodOperator (#68975)
---
 task-sdk/src/airflow/sdk/execution_time/context.py | 28 +++----
 .../tests/task_sdk/execution_time/test_context.py  | 93 ++++++++++++++++++++++
 2 files changed, 107 insertions(+), 14 deletions(-)

diff --git a/task-sdk/src/airflow/sdk/execution_time/context.py 
b/task-sdk/src/airflow/sdk/execution_time/context.py
index 2c2364a49b2..f0c982b32ea 100644
--- a/task-sdk/src/airflow/sdk/execution_time/context.py
+++ b/task-sdk/src/airflow/sdk/execution_time/context.py
@@ -317,6 +317,18 @@ async def _async_get_connection(conn_id: str) -> 
Connection:
     raise AirflowNotFoundException(f"The conn_id `{conn_id}` isn't defined")
 
 
+def _mask_and_deserialize_variable(raw: str, key: str, deserialize_json: bool) 
-> Any:
+    mask_secret(raw, key)
+    if not deserialize_json:
+        return raw
+    val = json.loads(raw)
+    if isinstance(val, str):
+        mask_secret(val, key)
+    elif isinstance(val, dict):
+        mask_secret(val)
+    return val
+
+
 def _get_variable(key: str, deserialize_json: bool) -> Any:
     from airflow.sdk.execution_time.cache import SecretCache
     from airflow.sdk.execution_time.supervisor import 
ensure_secrets_backend_loaded
@@ -325,13 +337,7 @@ def _get_variable(key: str, deserialize_json: bool) -> Any:
     try:
         var_val = SecretCache.get_variable(key)
         if var_val is not None:
-            if deserialize_json:
-                import json
-
-                var_val = json.loads(var_val)
-            if isinstance(var_val, str):
-                mask_secret(var_val, key)
-            return var_val
+            return _mask_and_deserialize_variable(var_val, key, 
deserialize_json)
     except SecretCache.NotPresentException:
         pass  # Continue to check backends
 
@@ -344,13 +350,7 @@ def _get_variable(key: str, deserialize_json: bool) -> Any:
             if var_val is not None:
                 # Save raw value before deserialization to maintain cache 
consistency
                 SecretCache.save_variable(key, var_val)
-                if deserialize_json:
-                    import json
-
-                    var_val = json.loads(var_val)
-                if isinstance(var_val, str):
-                    mask_secret(var_val, key)
-                return var_val
+                return _mask_and_deserialize_variable(var_val, key, 
deserialize_json)
         except AirflowSecretsBackendAccessDenied:
             # Authoritative deny — must NOT fall through to a less-restrictive 
backend.
             raise
diff --git a/task-sdk/tests/task_sdk/execution_time/test_context.py 
b/task-sdk/tests/task_sdk/execution_time/test_context.py
index 8d6e52c4b7f..1f4507e8055 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_context.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_context.py
@@ -360,6 +360,99 @@ class TestVariableAccessor:
         val = accessor.get("nonexistent_var_key", default="default_value")
         assert val == "default_value"
 
+    @mock.patch("airflow.sdk.execution_time.context.mask_secret")
+    def test_var_json_masks_from_cache(self, mock_mask_secret):
+        """SecretCache hit path applies the same masking as the backends 
path."""
+        from airflow.sdk.execution_time.cache import SecretCache
+
+        raw_json = '{"password": "s3cr3t", "host": "db.example.com"}'
+        with mock.patch.object(SecretCache, "get_variable", 
return_value=raw_json):
+            accessor = VariableAccessor(deserialize_json=True)
+            val = accessor.db_config
+
+        assert val == {"password": "s3cr3t", "host": "db.example.com"}
+        mock_mask_secret.assert_any_call(raw_json, "db_config")
+        mock_mask_secret.assert_any_call({"password": "s3cr3t", "host": 
"db.example.com"})
+
+    @mock.patch("airflow.sdk.execution_time.context.mask_secret")
+    def test_var_value_masks_secret(self, mock_mask_secret, 
mock_supervisor_comms):
+        """var.value.<key> masks the string value when the key name is 
sensitive."""
+        accessor = VariableAccessor(deserialize_json=False)
+        mock_supervisor_comms.send.return_value = 
VariableResult(key="my_password", value="s3cr3t")
+
+        val = accessor.my_password
+
+        assert val == "s3cr3t"
+        mock_mask_secret.assert_called_once_with("s3cr3t", "my_password")
+
+    @mock.patch("airflow.sdk.execution_time.context.mask_secret")
+    def test_var_json_masks_raw_string_and_dict_values(self, mock_mask_secret, 
mock_supervisor_comms):
+        """var.json.<key> masks both the raw JSON string and the deserialized 
dict's sensitive fields."""
+        accessor = VariableAccessor(deserialize_json=True)
+        raw_json = '{"password": "s3cr3t", "host": "db.example.com"}'
+        mock_supervisor_comms.send.return_value = 
VariableResult(key="db_config", value=raw_json)
+
+        val = accessor.db_config
+
+        assert val == {"password": "s3cr3t", "host": "db.example.com"}
+        # First call: raw JSON string with variable key (masks if key name is 
sensitive)
+        mock_mask_secret.assert_any_call(raw_json, "db_config")
+        # Second call: deserialized dict so internal sensitive fields like 
"password" get masked
+        mock_mask_secret.assert_any_call({"password": "s3cr3t", "host": 
"db.example.com"})
+
+    @mock.patch("airflow.sdk.execution_time.context.mask_secret")
+    def test_var_json_sensitive_key_masks_raw_json(self, mock_mask_secret, 
mock_supervisor_comms):
+        """var.json.<sensitive_key> masks the entire raw JSON string because 
the variable key is sensitive."""
+        accessor = VariableAccessor(deserialize_json=True)
+        raw_json = '{"endpoint": "https://api.example.com";, "token": "abc123"}'
+        mock_supervisor_comms.send.return_value = 
VariableResult(key="my_secret", value=raw_json)
+
+        val = accessor.my_secret
+
+        assert val == {"endpoint": "https://api.example.com";, "token": 
"abc123"}
+        mock_mask_secret.assert_any_call(raw_json, "my_secret")
+        mock_mask_secret.assert_any_call({"endpoint": 
"https://api.example.com";, "token": "abc123"})
+
+    @mock.patch("airflow.sdk.execution_time.context.mask_secret")
+    def test_var_json_string_value_masks_both_forms(self, mock_mask_secret, 
mock_supervisor_comms):
+        """var.json with a JSON string value masks both the quoted raw and the 
unquoted value."""
+        accessor = VariableAccessor(deserialize_json=True)
+        mock_supervisor_comms.send.return_value = 
VariableResult(key="my_token", value='"s3cr3t"')
+
+        val = accessor.my_token
+
+        assert val == "s3cr3t"
+        assert mock_mask_secret.call_count == 2
+        mock_mask_secret.assert_any_call('"s3cr3t"', "my_token")
+        mock_mask_secret.assert_any_call("s3cr3t", "my_token")
+
+    @mock.patch("airflow.sdk.execution_time.context.mask_secret")
+    def test_var_json_list_value_does_not_over_mask(self, mock_mask_secret, 
mock_supervisor_comms):
+        """var.json with a non-sensitive list variable does not mask 
individual list elements."""
+        accessor = VariableAccessor(deserialize_json=True)
+        raw_json = '["us-east-1", "eu-west-1"]'
+        mock_supervisor_comms.send.return_value = 
VariableResult(key="aws_regions", value=raw_json)
+
+        val = accessor.aws_regions
+
+        assert val == ["us-east-1", "eu-west-1"]
+        mock_mask_secret.assert_called_once_with(raw_json, "aws_regions")
+
+    @mock.patch("airflow.sdk.execution_time.context.mask_secret")
+    def test_var_json_invalid_json_raises(self, mock_mask_secret):
+        """Invalid JSON raises JSONDecodeError; the raw value is still masked 
before the error."""
+        import json
+
+        from airflow.sdk.execution_time.cache import SecretCache
+
+        raw = "not-valid-json"
+        with mock.patch.object(SecretCache, "get_variable", return_value=raw):
+            accessor = VariableAccessor(deserialize_json=True)
+            with pytest.raises(json.JSONDecodeError):
+                accessor.bad_var
+
+        mock_mask_secret.assert_called_once_with(raw, "bad_var")
+
 
 class TestCurrentContext:
     def test_current_context_roundtrip(self):

Reply via email to