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 c6a4acf8cde Fix multi-line secrets leaking in logs formatted with 
pprint (#75651)
c6a4acf8cde is described below

commit c6a4acf8cde88ee11a805435c1de3f3fb707e909
Author: juan-pablo-guereca <[email protected]>
AuthorDate: Thu Nov 12 20:21:39 2026 +0100

    Fix multi-line secrets leaking in logs formatted with pprint (#75651)
    
    * Mask multi-line secrets when logged through pprint-style formatting
    
    Secrets such as private keys were left readable in task logs when
    a Kubernetes pod spec was formatted into an exception message,
    because pprint splits a multi-line string into separate literals and
    the masker only looked for the secret as one contiguous string.
    
    * Make the multi-line secret masking test independent of pprint
    
    The regression test built its input with pprint.pformat, so a change in
    how pprint wraps strings could make the test pass without exercising
    the masker. Spell out the formatted text and the expected result in the
    test instead, and use raw strings for the expected regex patterns.
    
    * Re-trigger CI after a runner failure
    
    One MySQL test job on the previous commit failed with "The hosted
    runner lost communication with the server". It passed on the commit
    before and the other database jobs passed, but contributors cannot
    re-run individual jobs, so push an empty commit to start a new run.
    
    * Mask secrets that end with a line break
    
    A value read from a file usually ends with a newline, and pprint prints
    it with the newline escaped, so the registered secret never matched and
    stayed readable in logs. Register the stripped value of any secret that
    contains a line break, not only of secrets with several lines.
    
    * Document per-line masking of multi-line values
    
    Multi-line secrets are now also masked line by line, which can replace
    ordinary text when a pretty-printed connection extra is registered.
    Describe both effects so readers who find *** in place of plain text
    know where it comes from.
---
 .../security/secrets/mask-sensitive-values.rst     | 11 +++++
 .../secrets_masker/secrets_masker.py               |  6 +++
 .../tests/secrets_masker/test_secrets_masker.py    | 51 ++++++++++++++++++++++
 3 files changed, 68 insertions(+)

diff --git a/airflow-core/docs/security/secrets/mask-sensitive-values.rst 
b/airflow-core/docs/security/secrets/mask-sensitive-values.rst
index 780aceb70fb..ae189dc5dfc 100644
--- a/airflow-core/docs/security/secrets/mask-sensitive-values.rst
+++ b/airflow-core/docs/security/secrets/mask-sensitive-values.rst
@@ -29,6 +29,17 @@ It does this by looking for the specific *value* appearing 
anywhere in your outp
 have a connection with a password of ``a``, then every instance of the letter 
a in your logs will be replaced
 with ``***``.
 
+A value that spans several lines, such as a private key, is also masked line 
by line: each eligible non-blank line,
+with surrounding whitespace removed, is masked in addition to the whole value. 
This also masks matching lines when
+a formatter such as ``pprint`` splits a multi-line string into separate string 
literals, as happens when a
+Kubernetes pod spec is printed. Lines shorter than 
:ref:`config:logging__min_length_masked_secret` are not masked
+on their own.
+
+This also applies to a Connection's extra field that is stored as 
pretty-printed, multi-line JSON: each eligible
+line of it, such as ``"timeout": 30,``, is replaced with ``***`` in your logs, 
including in unrelated text.
+Compact, single-line JSON does not have this effect, but saving the connection 
in the UI formats extra with
+indentation again.
+
 To disable masking you can set 
:ref:`config:core__hide_sensitive_var_conn_fields` to false.
 
 The automatic masking is triggered by Connection or Variable access. This 
means that if you pass a sensitive
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 d34d9b0a263..63d2827ca04 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
@@ -592,6 +592,12 @@ class SecretsMasker(logging.Filter):
         """Yield the secret along with any adaptations to the secret that 
should be masked."""
         yield secret
 
+        # Formatters such as ``pprint`` (used by ``str()`` of Kubernetes 
models) escape newlines and split a
+        # multi-line string across several literals, so the secret never 
appears contiguously in the output.
+        lines = secret.splitlines()
+        if lines != [secret]:
+            yield from map(str.strip, lines)
+
         if self.secret_mask_adapter:
             # This can return an iterable of secrets to mask OR a single 
secret as a string
             secret_or_secrets = self.secret_mask_adapter(secret)
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 1d692e53faa..fac109a4231 100644
--- a/shared/secrets_masker/tests/secrets_masker/test_secrets_masker.py
+++ b/shared/secrets_masker/tests/secrets_masker/test_secrets_masker.py
@@ -1266,6 +1266,57 @@ class TestEdgeCases:
         assert redacted.startswith("Contains ")
         assert " and " in redacted
 
+    def test_multiline_secret_masked_when_split_into_adjacent_literals(self):
+        secret = "-----BEGIN KEY-----\nabcDEF123\nxyz789\n-----END KEY-----"
+        secrets_masker = SecretsMasker()
+        configure_secrets_masker_for_test(secrets_masker)
+        secrets_masker.add_mask(secret)
+
+        formatted = textwrap.dedent(
+            r"""
+            {'name': 'MULTI',
+             'value': '-----BEGIN KEY-----\n'
+                      'abcDEF123\n'
+                      'xyz789\n'
+                      '-----END KEY-----'}
+            """
+        )
+        expected = textwrap.dedent(
+            r"""
+            {'name': 'MULTI',
+             'value': '***\n'
+                      '***\n'
+                      '***\n'
+                      '***'}
+            """
+        )
+
+        assert secrets_masker.redact(formatted) == expected
+
+    @pytest.mark.parametrize("newline", ["\n", "\r\n"])
+    def test_multiline_secret_registers_stripped_lines(self, newline):
+        secret = newline.join(["  first-line-1  ", "", "second-line-2"])
+        secrets_masker = SecretsMasker()
+        configure_secrets_masker_for_test(secrets_masker)
+        secrets_masker.add_mask(secret)
+
+        assert {r"first\-line\-1", r"second\-line\-2"} <= 
secrets_masker.patterns
+        assert secrets_masker.redact(f"x {secret} y") == "x *** y"
+
+        single_line_masker = SecretsMasker()
+        configure_secrets_masker_for_test(single_line_masker)
+        single_line_masker.add_mask("  error  ")
+        assert single_line_masker.patterns == {r"\ \ error\ \ "}
+
+    @pytest.mark.parametrize(("newline", "escaped"), [("\n", r"\n"), ("\r\n", 
r"\r\n")])
+    def test_secret_with_trailing_newline_registers_stripped_value(self, 
newline, escaped):
+        secrets_masker = SecretsMasker()
+        configure_secrets_masker_for_test(secrets_masker)
+        secrets_masker.add_mask(f"token-value{newline}")
+
+        assert r"token\-value" in secrets_masker.patterns
+        assert secrets_masker.redact(f"{{'v': 'token-value{escaped}'}}") == 
f"{{'v': '***{escaped}'}}"
+
 
 class TestDirectMethodCalls:
     def test_redact_all_directly(self):

Reply via email to