Copilot commented on code in PR #61776:
URL: https://github.com/apache/airflow/pull/61776#discussion_r2795611368


##########
shared/secrets_masker/tests/secrets_masker/test_secrets_masker.py:
##########
@@ -238,6 +238,343 @@ def test_masking_in_explicit_context_exceptions(self, 
logger, caplog):
             """
         )
 
+    def test_redact_exception_with_context_simple(self):
+        """
+        Test _redact_exception_with_context with a simple exception without 
context.

Review Comment:
   This docstring/test text still refers to `_redact_exception_with_context`, 
but the method under test is now `_redact_exception_with_context_or_cause`. 
Please rename the wording here to match the new method name (and mention both 
context/cause if that’s what’s being exercised).
   ```suggestion
           Test _redact_exception_with_context_or_cause with a simple exception 
without context or cause.
   ```



##########
shared/secrets_masker/tests/secrets_masker/test_secrets_masker.py:
##########
@@ -238,6 +238,343 @@ def test_masking_in_explicit_context_exceptions(self, 
logger, caplog):
             """
         )
 
+    def test_redact_exception_with_context_simple(self):
+        """
+        Test _redact_exception_with_context with a simple exception without 
context.
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        exc = RuntimeError(f"Cannot connect to user:{PASSWORD}")
+        masker._redact_exception_with_context_or_cause(exc)
+
+        assert "password" not in str(exc.args[0])
+        assert "user:***" in str(exc.args[0])
+
+    def test_redact_exception_with_implicit_context(self):
+        """
+        Test _redact_exception_with_context with exception __context__ 
(implicit chaining).
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        try:
+            try:
+                raise RuntimeError(f"Inner error with {PASSWORD}")
+            except RuntimeError:
+                raise RuntimeError(f"Outer error with {PASSWORD}")
+        except RuntimeError as exc:
+            captured_exc = exc
+
+        masker._redact_exception_with_context_or_cause(captured_exc)
+        assert "password" not in str(captured_exc.args[0])
+        assert "password" not in str(captured_exc.__context__.args[0])
+
+    def test_redact_exception_with_explicit_cause(self):
+        """
+        Test _redact_exception_with_context with exception __cause__ (explicit 
chaining).
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        try:
+            inner = RuntimeError(f"Cause error: {PASSWORD}")
+            raise RuntimeError(f"Main error: {PASSWORD}") from inner
+        except RuntimeError as exc:
+            captured_exc = exc
+
+        masker._redact_exception_with_context_or_cause(captured_exc)
+        assert "password" not in str(captured_exc.args[0])
+        assert "password" not in str(captured_exc.__cause__.args[0])
+
+    def test_redact_exception_with_circular_context_reference(self):
+        """
+        Test _redact_exception_with_context handles circular references 
without infinite recursion.
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        exc1 = RuntimeError(f"Error with {PASSWORD}")
+        exc2 = RuntimeError(f"Another error with {PASSWORD}")
+        # Create circular reference
+        exc1.__context__ = exc2
+        exc2.__context__ = exc1
+
+        # Should not raise RecursionError
+        masker._redact_exception_with_context_or_cause(exc1)
+
+        assert "password" not in str(exc1.args[0])
+        assert "password" not in str(exc2.args[0])
+
+    def test_redact_exception_with_max_context_recursion_depth(self):
+        """
+        Test _redact_exception_with_context respects MAX_RECURSION_DEPTH.
+        Exceptions beyond the depth limit should be skipped (not redacted).

Review Comment:
   The docstring here says exceptions beyond the depth limit are "skipped (not 
redacted)", but the implementation replaces the truncated tail with a sentinel 
exception (and drops further chaining). Update the docstring to reflect the 
sentinel/truncation behavior so the test intent is accurate.
   ```suggestion
           Once the depth limit is reached, the remaining exception chain is 
not traversed;
           instead, it is truncated and replaced with a sentinel exception 
indicating the
           recursion limit was hit, and further chaining is dropped.
   ```



##########
shared/secrets_masker/src/airflow_shared/secrets_masker/secrets_masker.py:
##########
@@ -260,15 +260,38 @@ def _record_attrs_to_ignore(self) -> Iterable[str]:
         )
         return frozenset(record.__dict__).difference({"msg", "args"})
 
-    def _redact_exception_with_context(self, exception):
+    def _redact_exception_with_context_or_cause(self, exception, visited=None):
         # Exception class may not be modifiable (e.g. declared by an
         # extension module such as JDBC).
         with contextlib.suppress(AttributeError):
-            exception.args = (self.redact(v) for v in exception.args)
-        if exception.__context__:
-            self._redact_exception_with_context(exception.__context__)
-        if exception.__cause__ and exception.__cause__ is not 
exception.__context__:
-            self._redact_exception_with_context(exception.__cause__)
+            if visited is None:
+                visited = set()

Review Comment:
   `contextlib.suppress(AttributeError)` currently wraps the entire traversal, 
not just the `exception.args` assignment. If an exception type has non-settable 
`args` (the comment mentions extension-module exceptions), the AttributeError 
will cause the function to skip redacting/truncating `__context__`/`__cause__`, 
potentially leaving sensitive values unredacted. Narrow the 
`suppress(AttributeError)` scope to only the `exception.args = ...` line (and 
optionally to assignments to `__context__`/`__cause__` if needed), while 
keeping visited/recursion-limit logic outside the suppress block.



##########
shared/secrets_masker/tests/secrets_masker/test_secrets_masker.py:
##########
@@ -238,6 +238,343 @@ def test_masking_in_explicit_context_exceptions(self, 
logger, caplog):
             """
         )
 
+    def test_redact_exception_with_context_simple(self):
+        """
+        Test _redact_exception_with_context with a simple exception without 
context.
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        exc = RuntimeError(f"Cannot connect to user:{PASSWORD}")
+        masker._redact_exception_with_context_or_cause(exc)
+
+        assert "password" not in str(exc.args[0])
+        assert "user:***" in str(exc.args[0])
+
+    def test_redact_exception_with_implicit_context(self):
+        """
+        Test _redact_exception_with_context with exception __context__ 
(implicit chaining).
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        try:
+            try:
+                raise RuntimeError(f"Inner error with {PASSWORD}")
+            except RuntimeError:
+                raise RuntimeError(f"Outer error with {PASSWORD}")
+        except RuntimeError as exc:
+            captured_exc = exc
+
+        masker._redact_exception_with_context_or_cause(captured_exc)
+        assert "password" not in str(captured_exc.args[0])
+        assert "password" not in str(captured_exc.__context__.args[0])
+
+    def test_redact_exception_with_explicit_cause(self):
+        """
+        Test _redact_exception_with_context with exception __cause__ (explicit 
chaining).
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        try:
+            inner = RuntimeError(f"Cause error: {PASSWORD}")
+            raise RuntimeError(f"Main error: {PASSWORD}") from inner
+        except RuntimeError as exc:
+            captured_exc = exc
+
+        masker._redact_exception_with_context_or_cause(captured_exc)
+        assert "password" not in str(captured_exc.args[0])
+        assert "password" not in str(captured_exc.__cause__.args[0])
+
+    def test_redact_exception_with_circular_context_reference(self):
+        """
+        Test _redact_exception_with_context handles circular references 
without infinite recursion.
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        exc1 = RuntimeError(f"Error with {PASSWORD}")
+        exc2 = RuntimeError(f"Another error with {PASSWORD}")
+        # Create circular reference
+        exc1.__context__ = exc2
+        exc2.__context__ = exc1
+
+        # Should not raise RecursionError
+        masker._redact_exception_with_context_or_cause(exc1)
+
+        assert "password" not in str(exc1.args[0])
+        assert "password" not in str(exc2.args[0])
+
+    def test_redact_exception_with_max_context_recursion_depth(self):
+        """
+        Test _redact_exception_with_context respects MAX_RECURSION_DEPTH.
+        Exceptions beyond the depth limit should be skipped (not redacted).
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        # Create a long chain of exceptions
+        exc_chain = RuntimeError(f"Error 0 with {PASSWORD}")
+        current = exc_chain
+        for i in range(1, 10):
+            new_exc = RuntimeError(f"Error {i} with {PASSWORD}")
+            current.__context__ = new_exc
+            current = new_exc
+
+        masker._redact_exception_with_context_or_cause(exc_chain)
+
+        # Verify redaction happens up to MAX_RECURSION_DEPTH
+        # The check is `len(visited) >= MAX_RECURSION_DEPTH` before adding 
current exception
+        # So it processes exactly MAX_RECURSION_DEPTH exceptions (0 through 
MAX_RECURSION_DEPTH-1)
+        current = exc_chain
+        for i in range(10):
+            assert current, "We should always get some exception here"
+            if i < masker.MAX_RECURSION_DEPTH:
+                # Should be redacted within the depth limit
+                assert "password" not in str(current.args[0]), f"Exception {i} 
should be redacted"
+            else:
+                assert "hit recursion limit" in str(current.args[0]), (
+                    f"Exception {i} should indicate recursion depth limit hit"
+                )
+                assert "password" not in str(current.args[0]), f"Exception {i} 
should not be present"
+                assert current.__context__ is None, (
+                    f"Exception {i} should not have a context due to depth 
limit"
+                )
+                break
+            current = current.__context__
+
+    def test_redact_exception_with_circular_cause_reference(self):
+        """
+        Test _redact_exception_with_context_or_cause handles circular 
__cause__ references without infinite recursion.
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        exc1 = RuntimeError(f"Error with {PASSWORD}")
+        exc2 = RuntimeError(f"Another error with {PASSWORD}")
+        # Create circular reference using __cause__
+        exc1.__cause__ = exc2
+        exc2.__cause__ = exc1
+
+        # Should not raise RecursionError
+        masker._redact_exception_with_context_or_cause(exc1)
+
+        assert "password" not in str(exc1.args[0])
+        assert "password" not in str(exc2.args[0])
+
+    def test_redact_exception_with_max_cause_recursion_depth(self):
+        """
+        Test _redact_exception_with_context_or_cause respects 
MAX_RECURSION_DEPTH for __cause__ chains.
+        Exceptions beyond the depth limit should be skipped (not redacted).
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        # Create a long chain of exceptions using __cause__
+        exc_chain = RuntimeError(f"Error 0 with {PASSWORD}")
+        current = exc_chain
+        for i in range(1, 10):
+            new_exc = RuntimeError(f"Error {i} with {PASSWORD}")
+            current.__cause__ = new_exc
+            current = new_exc
+
+        masker._redact_exception_with_context_or_cause(exc_chain)
+
+        # Verify redaction happens up to MAX_RECURSION_DEPTH
+        # The check is `len(visited) >= MAX_RECURSION_DEPTH` before adding 
current exception
+        # So it processes exactly MAX_RECURSION_DEPTH exceptions (0 through 
MAX_RECURSION_DEPTH-1)
+        current = exc_chain
+        for i in range(10):
+            assert current, "We should always get some exception here"
+            if i < masker.MAX_RECURSION_DEPTH:
+                # Should be redacted within the depth limit
+                assert "password" not in str(current.args[0]), f"Exception {i} 
should be redacted"
+            else:
+                assert "hit recursion limit" in str(current.args[0]), (
+                    f"Exception {i} should indicate recursion depth limit hit"
+                )
+                assert "password" not in str(current.args[0]), f"Exception {i} 
should not be present"
+                assert current.__cause__ is None, f"Exception {i} should not 
have a cause due to depth limit"
+                break
+            current = current.__cause__
+
+    def test_redact_exception_with_mixed_cause_and_context_linear(self):
+        """
+        Test _redact_exception_with_context_or_cause with mixed __cause__ and 
__context__ in a linear chain.
+        This simulates: exception with cause, which has context, which has 
cause, etc.
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        # Build a chain: exc1 -> (cause) -> exc2 -> (context) -> exc3 -> 
(cause) -> exc4
+        exc4 = RuntimeError(f"Error 4 with {PASSWORD}")
+        exc3 = RuntimeError(f"Error 3 with {PASSWORD}")
+        exc3.__cause__ = exc4
+        exc2 = RuntimeError(f"Error 2 with {PASSWORD}")
+        exc2.__context__ = exc3
+        exc1 = RuntimeError(f"Error 1 with {PASSWORD}")
+        exc1.__cause__ = exc2
+
+        masker._redact_exception_with_context_or_cause(exc1)
+
+        # All exceptions should be redacted
+        assert "password" not in str(exc1.args[0])
+        assert "password" not in str(exc2.args[0])
+        assert "password" not in str(exc3.args[0])
+        assert "password" not in str(exc4.args[0])
+
+    def test_redact_exception_with_mixed_cause_and_context_branching(self):
+        """
+        Test with an exception that has both __cause__ and __context__ 
pointing to different exceptions.
+        This creates a branching structure.
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        # Create branching structure:
+        #     exc1
+        #    /    \
+        # cause  context
+        #  |       |
+        # exc2   exc3
+        exc2 = RuntimeError(f"Cause error with {PASSWORD}")
+        exc3 = RuntimeError(f"Context error with {PASSWORD}")
+        exc1 = RuntimeError(f"Main error with {PASSWORD}")
+        exc1.__cause__ = exc2
+        exc1.__context__ = exc3
+
+        masker._redact_exception_with_context_or_cause(exc1)
+
+        # All three should be redacted
+        assert "password" not in str(exc1.args[0])
+        assert "password" not in str(exc2.args[0])
+        assert "password" not in str(exc3.args[0])
+
+    def test_redact_exception_with_mixed_circular_reference(self):
+        """
+        Test with circular references involving both __cause__ and __context__.
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        # Create circular mixed reference: exc1 -cause-> exc2 -context-> exc1
+        exc1 = RuntimeError(f"Error 1 with {PASSWORD}")
+        exc2 = RuntimeError(f"Error 2 with {PASSWORD}")
+        exc1.__cause__ = exc2
+        exc2.__context__ = exc1
+
+        # Should not raise RecursionError
+        masker._redact_exception_with_context_or_cause(exc1)
+
+        assert "password" not in str(exc1.args[0])
+        assert "password" not in str(exc2.args[0])
+
+    def test_redact_exception_with_mixed_deep_chain(self):
+        """
+        Test with a deep chain alternating between __cause__ and __context__.
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        # Create alternating chain exceeding MAX_RECURSION_DEPTH
+        exceptions = [RuntimeError(f"Error {i} with {PASSWORD}") for i in 
range(10)]
+
+        # Link them: 0 -cause-> 1 -context-> 2 -cause-> 3 -context-> 4 ...
+        for i in range(len(exceptions) - 1):
+            if i % 2 == 0:
+                exceptions[i].__cause__ = exceptions[i + 1]
+            else:
+                exceptions[i].__context__ = exceptions[i + 1]
+
+        masker._redact_exception_with_context_or_cause(exceptions[0])
+
+        # Check that first MAX_RECURSION_DEPTH are redacted, rest hit the limit
+        for i in range(min(len(exceptions), masker.MAX_RECURSION_DEPTH)):
+            assert "password" not in str(exceptions[i].args[0]), f"Exception 
{i} should be redacted"
+
+    def test_redact_exception_with_mixed_diamond_structure(self):
+        """
+        Test with diamond structure: top exception has both cause and context 
that converge to same exception.
+                exc1
+               /    \
+           cause  context
+             |      |
+           exc2   exc3
+             \\     /
+              exc4
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        exc4 = RuntimeError(f"Bottom error with {PASSWORD}")
+        exc2 = RuntimeError(f"Left error with {PASSWORD}")
+        exc2.__cause__ = exc4
+        exc3 = RuntimeError(f"Right error with {PASSWORD}")
+        exc3.__context__ = exc4
+        exc1 = RuntimeError(f"Top error with {PASSWORD}")
+        exc1.__cause__ = exc2
+        exc1.__context__ = exc3
+
+        masker._redact_exception_with_context_or_cause(exc1)
+
+        # All should be redacted, exc4 should be visited only once
+        assert "password" not in str(exc1.args[0])
+        assert "password" not in str(exc2.args[0])
+        assert "password" not in str(exc3.args[0])
+        assert "password" not in str(exc4.args[0])
+
+    def test_redact_exception_with_immutable_args(self):
+        """
+        Test _redact_exception_with_context handles exceptions with immutable 
args gracefully.
+        """
+        masker = SecretsMasker()

Review Comment:
   `test_redact_exception_with_immutable_args` only asserts the method doesn’t 
crash. Since the production logic should still traverse/redact 
`__context__`/`__cause__` even when `args` can’t be set, this test won’t catch 
regressions where an `AttributeError` stops redaction of chained exceptions. 
Consider adding a `__context__`/`__cause__` containing a secret and asserting 
it still gets redacted/truncated.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to