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 1b43dce4466 Mask sensitive values in airflow config update dry-run 
output (#73265)
1b43dce4466 is described below

commit 1b43dce4466229125cfcd07283a3df3d622a8230
Author: Keith <[email protected]>
AuthorDate: Mon Nov 2 09:38:05 2026 +0900

    Mask sensitive values in airflow config update dry-run output (#73265)
    
    The dry run, the default mode of airflow config update, printed the 
regenerated airflow.cfg with real values of sql_alchemy_conn, fernet_key and 
other sensitive options, and that preview output easily ends up in CI logs and 
bug reports.
    
    closes: #73255
    Generated-by: Claude Opus 5
---
 airflow-core/src/airflow/cli/cli_config.py         | 11 ++++
 .../src/airflow/cli/commands/config_command.py     | 13 +++++
 airflow-core/src/airflow/configuration.py          | 18 ++++++-
 .../tests/unit/cli/commands/test_config_command.py | 59 ++++++++++++++++++++++
 4 files changed, 100 insertions(+), 1 deletion(-)

diff --git a/airflow-core/src/airflow/cli/cli_config.py 
b/airflow-core/src/airflow/cli/cli_config.py
index 6f9be99f3c2..7bc1517700f 100644
--- a/airflow-core/src/airflow/cli/cli_config.py
+++ b/airflow-core/src/airflow/cli/cli_config.py
@@ -1039,6 +1039,16 @@ ARG_UPDATE_ALL_RECOMMENDATIONS = Arg(
     action="store_true",
 )
 
+ARG_UPDATE_CONFIG_SHOW_SENSITIVE = Arg(
+    ("--show-sensitive",),
+    help=(
+        "Show real values of sensitive options (passwords, keys, tokens, etc.) 
in the dry-run "
+        "preview instead of masking them as '< hidden >'. Has no effect 
together with --fix, "
+        "which always writes real values to airflow.cfg."
+    ),
+    action="store_true",
+)
+
 
 # jobs check
 ARG_JOB_TYPE_FILTER = Arg(
@@ -2087,6 +2097,7 @@ CONFIG_COMMANDS = (
             ARG_VERBOSE,
             ARG_UPDATE_CONFIG_FIX,
             ARG_UPDATE_ALL_RECOMMENDATIONS,
+            ARG_UPDATE_CONFIG_SHOW_SENSITIVE,
         ),
     ),
 )
diff --git a/airflow-core/src/airflow/cli/commands/config_command.py 
b/airflow-core/src/airflow/cli/commands/config_command.py
index 4dc38a38cc7..3a85cc18100 100644
--- a/airflow-core/src/airflow/cli/commands/config_command.py
+++ b/airflow-core/src/airflow/cli/commands/config_command.py
@@ -978,6 +978,12 @@ def update_config(args) -> None:
             Comma-separated list of configuration options to ignore during 
update.
             Example: --ignore-option check_slas
 
+        --show-sensitive: flag (optional)
+            Show real values of sensitive options in the dry-run preview. By 
default sensitive
+            values (passwords, keys, tokens, etc.) are masked as '< hidden >'. 
Has no effect
+            together with --fix, which always writes real values to 
airflow.cfg.
+            Example: --show-sensitive
+
     Examples:
         1. Dry-run mode (print the changes in modified airflow.cfg) showing 
only breaking changes:
             airflow config update
@@ -1075,12 +1081,19 @@ def update_config(args) -> None:
 
     if dry_run:
         console.print("[blue]Dry-run mode enabled. No changes will be written 
to airflow.cfg.[/blue]")
+        hide_sensitive = not args.show_sensitive
+        if hide_sensitive:
+            console.print(
+                "[blue]Sensitive values are shown as '< hidden >'. Use 
`--show-sensitive` to "
+                "display them (never paste unmasked output into logs or bug 
reports).[/blue]"
+            )
         with StringIO() as config_output:
             conf.write_custom_config(
                 file=config_output,
                 comment_out_defaults=True,
                 include_descriptions=True,
                 modifications=modifications,
+                hide_sensitive=hide_sensitive,
             )
             new_config = config_output.getvalue()
         console.print(new_config)
diff --git a/airflow-core/src/airflow/configuration.py 
b/airflow-core/src/airflow/configuration.py
index 2fe2e5e2f00..f8a554a5dc3 100644
--- a/airflow-core/src/airflow/configuration.py
+++ b/airflow-core/src/airflow/configuration.py
@@ -63,6 +63,12 @@ ConfigSourcesType = dict[str, ConfigSectionSourcesType]
 
 ENV_VAR_PREFIX = "AIRFLOW__"
 
+_AIRFLOW_V2_SENSITIVE_OPTIONS = {
+    ("core", "dataset_manager_kwargs"),
+    ("core", "internal_api_secret_key"),
+    ("smtp", "smtp_password"),
+}
+
 
 class _SecretKeys:
     """Holds the secret keys used in Airflow during runtime."""
@@ -282,6 +288,7 @@ class AirflowConfigParser(_SharedAirflowConfigParser):
         include_descriptions: bool = True,
         extra_spacing: bool = True,
         modifications: ConfigModifications | None = None,
+        hide_sensitive: bool = False,
     ) -> None:
         """
         Write a configuration file using a ConfigModifications object.
@@ -298,6 +305,8 @@ class AirflowConfigParser(_SharedAirflowConfigParser):
         :param include_descriptions: Whether to include section descriptions.
         :param extra_spacing: Whether to insert an extra blank line after each 
option.
         :param modifications: ConfigModifications instance with rename, 
remove, and default updates.
+        :param hide_sensitive: If True, values of options registered as 
sensitive are written as
+            ``< hidden >``. Only suitable for preview output, never for a real 
configuration file.
         """
         modifications = modifications or ConfigModifications()
         output: dict[str, list[tuple[str, str, bool, str]]] = {}
@@ -327,8 +336,15 @@ class AirflowConfigParser(_SharedAirflowConfigParser):
 
                 default_value = self.get_default_value(effective_section, 
effective_option, fallback="")
                 is_default = str(value) == str(default_value)
+                value_to_write = str(value)
+                if hide_sensitive and (
+                    key in _AIRFLOW_V2_SENSITIVE_OPTIONS
+                    or self.is_sensitive_option(section, option)
+                    or self.is_sensitive_option(effective_section, 
effective_option)
+                ):
+                    value_to_write = "< hidden >"
                 output.setdefault(effective_section.lower(), []).append(
-                    (effective_option, str(value), is_default, mod_comment)
+                    (effective_option, value_to_write, is_default, mod_comment)
                 )
 
         for section, options in output.items():
diff --git a/airflow-core/tests/unit/cli/commands/test_config_command.py 
b/airflow-core/tests/unit/cli/commands/test_config_command.py
index 5d634bb13bf..90daf86e182 100644
--- a/airflow-core/tests/unit/cli/commands/test_config_command.py
+++ b/airflow-core/tests/unit/cli/commands/test_config_command.py
@@ -741,3 +741,62 @@ class TestCliConfigUpdate:
         output = capsys.readouterr().out
         assert f"'{present_key}'" in output
         assert f"'{absent_key}'" not in output
+
+    @conf_vars(
+        {
+            ("database", "sql_alchemy_conn"): 
"postgresql://airflow:S3cr3tPassword@db:5432/airflow",
+            ("core", "dags_folder"): "/opt/airflow/dags",
+            ("core", "dataset_manager_kwargs"): '{"password": 
"LegacyDatasetSecret"}',
+            ("core", "internal_api_secret_key"): "LegacyInternalApiSecret",
+            ("smtp", "smtp_password"): "LegacySmtpSecret",
+        }
+    )
+    def test_update_config_dry_run_masks_sensitive_values(self, tmp_path, 
monkeypatch, capsys):
+        cfg_file = tmp_path / "airflow.cfg"
+        cfg_file.write_text("[core]\nexecutor = SequentialExecutor\n")
+        monkeypatch.setattr(config_command, "AIRFLOW_CONFIG", str(cfg_file))
+
+        parser = cli_parser.get_parser()
+        args = parser.parse_args(["config", "update"])
+
+        config_command.update_config(args)
+
+        output = capsys.readouterr().out
+        assert "S3cr3tPassword" not in output
+        assert "LegacyDatasetSecret" not in output
+        assert "LegacyInternalApiSecret" not in output
+        assert "LegacySmtpSecret" not in output
+        assert "< hidden >" in output
+        assert "/opt/airflow/dags" in output, "Non-sensitive values must stay 
visible in the preview."
+        assert "Use `--show-sensitive` to" in output
+
+    @conf_vars({("database", "sql_alchemy_conn"): 
"postgresql://airflow:S3cr3tPassword@db:5432/airflow"})
+    def test_update_config_dry_run_show_sensitive_reveals_values(self, 
tmp_path, monkeypatch, capsys):
+        cfg_file = tmp_path / "airflow.cfg"
+        cfg_file.write_text("[core]\nexecutor = SequentialExecutor\n")
+        monkeypatch.setattr(config_command, "AIRFLOW_CONFIG", str(cfg_file))
+
+        parser = cli_parser.get_parser()
+        args = parser.parse_args(["config", "update", "--show-sensitive"])
+
+        config_command.update_config(args)
+
+        output = capsys.readouterr().out
+        assert "S3cr3tPassword" in output
+        assert "Use `--show-sensitive` to" not in output
+
+    @conf_vars({("database", "sql_alchemy_conn"): 
"postgresql://airflow:S3cr3tPassword@db:5432/airflow"})
+    def test_update_config_fix_always_writes_real_values(self, tmp_path, 
monkeypatch, capsys):
+        cfg_file = tmp_path / "airflow.cfg"
+        cfg_file.write_text("[core]\nexecutor = SequentialExecutor\n")
+        monkeypatch.setattr(config_command, "AIRFLOW_CONFIG", str(cfg_file))
+        monkeypatch.setattr(shutil, "copy2", lambda src, dst: None)
+
+        parser = cli_parser.get_parser()
+        args = parser.parse_args(["config", "update", "--fix"])
+
+        config_command.update_config(args)
+
+        written_cfg = cfg_file.read_text()
+        assert "S3cr3tPassword" in written_cfg, "--fix must write real values 
to airflow.cfg."
+        assert "< hidden >" not in written_cfg

Reply via email to