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