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 83c65143d6e feat: hide sensitive values by default in CLI 
connections/variables list (#62344)
83c65143d6e is described below

commit 83c65143d6e30385e2487f8c24846767aeb854ad
Author: André Ahlert <[email protected]>
AuthorDate: Wed Mar 11 21:27:38 2026 -0300

    feat: hide sensitive values by default in CLI connections/variables list 
(#62344)
    
    * cli: hide connection and variable values in list by default (#59844)
    
    - connections list: show only conn_id and conn_type by default
    - Add --show-values to display full connection details
    - Add --hide-sensitive to mask password, get_uri, and extra when showing 
values
    - variables list: add --show-values and --hide-sensitive for consistency
    - Add tests for default hide, show-values, and show-values with 
hide-sensitive
    
    Fixes #59844
    
    Signed-off-by: André Ahlert <[email protected]>
    
    * feat: hide sensitive values by default in CLI connections/variables list
    
    - Add --show-values and --hide-sensitive flags to connections list
    - Add --show-values and --hide-sensitive flags to variables list
    - Default behavior now shows only IDs/keys, hiding sensitive data
    - Optimize variables query to avoid unnecessary decryption
    - Implement smart URI credential masking
    - Add comprehensive tests for edge cases and security scenarios
    - Refactor mapper logic into dedicated classes for maintainability
    
    Closes #59844
    
    Signed-off-by: André Ahlert <[email protected]>
    
    * Fix import ordering and test assertions for CLI list commands
    
    Move imports to top of file in connection_command.py and 
variable_command.py (were incorrectly placed after function/class definitions). 
Fix test assertions to call command function instead of relying on argparse for 
SystemExit.
    
    Signed-off-by: André Ahlert <[email protected]>
    
    * Clean up code quality issues and add newsfragment
    
    Signed-off-by: André Ahlert <[email protected]>
    
    ---------
    
    Signed-off-by: André Ahlert <[email protected]>
---
 airflow-core/newsfragments/62344.feature.rst       |   1 +
 airflow-core/src/airflow/cli/cli_config.py         |  24 ++++-
 .../src/airflow/cli/commands/connection_command.py | 116 +++++++++++++++++----
 .../src/airflow/cli/commands/variable_command.py   |  48 ++++++++-
 airflow-core/src/airflow/cli/utils.py              |   3 +
 .../unit/cli/commands/test_connection_command.py   |  76 ++++++++++++++
 .../unit/cli/commands/test_variable_command.py     |  92 ++++++++++++++++
 7 files changed, 336 insertions(+), 24 deletions(-)

diff --git a/airflow-core/newsfragments/62344.feature.rst 
b/airflow-core/newsfragments/62344.feature.rst
new file mode 100644
index 00000000000..7fd43b0c03f
--- /dev/null
+++ b/airflow-core/newsfragments/62344.feature.rst
@@ -0,0 +1 @@
+CLI ``connections list`` and ``variables list`` now hide sensitive values by 
default. Use ``--show-values`` to display full details and ``--hide-sensitive`` 
to mask passwords, URIs, and extras.
diff --git a/airflow-core/src/airflow/cli/cli_config.py 
b/airflow-core/src/airflow/cli/cli_config.py
index 7bc5b94f9bc..8510eba3180 100644
--- a/airflow-core/src/airflow/cli/cli_config.py
+++ b/airflow-core/src/airflow/cli/cli_config.py
@@ -605,6 +605,16 @@ ARG_VAR_ACTION_ON_EXISTING_KEY = Arg(
     default="overwrite",
     choices=("overwrite", "fail", "skip"),
 )
+ARG_VAR_LIST_SHOW_VALUES = Arg(
+    ("--show-values",),
+    action="store_true",
+    help="Show variable values. By default only variable keys are listed.",
+)
+ARG_VAR_LIST_HIDE_SENSITIVE = Arg(
+    ("--hide-sensitive",),
+    action="store_true",
+    help="When used with --show-values, mask variable values.",
+)
 
 # kerberos
 ARG_PRINCIPAL = Arg(("principal",), help="kerberos principal", nargs="?")
@@ -812,6 +822,16 @@ ARG_CONN_OVERWRITE = Arg(
     required=False,
     action="store_true",
 )
+ARG_CONN_LIST_SHOW_VALUES = Arg(
+    ("--show-values",),
+    action="store_true",
+    help="Show connection values (host, login, URI, etc.). By default only 
connection IDs are listed.",
+)
+ARG_CONN_LIST_HIDE_SENSITIVE = Arg(
+    ("--hide-sensitive",),
+    action="store_true",
+    help="When used with --show-values, mask sensitive values (passwords, URI 
credentials, extra).",
+)
 
 # providers
 ARG_PROVIDER_NAME = Arg(
@@ -1429,7 +1449,7 @@ VARIABLES_COMMANDS = (
         name="list",
         help="List variables",
         
func=lazy_load_command("airflow.cli.commands.variable_command.variables_list"),
-        args=(ARG_OUTPUT, ARG_VERBOSE),
+        args=(ARG_OUTPUT, ARG_VAR_LIST_SHOW_VALUES, 
ARG_VAR_LIST_HIDE_SENSITIVE, ARG_VERBOSE),
     ),
     ActionCommand(
         name="get",
@@ -1604,7 +1624,7 @@ CONNECTIONS_COMMANDS = (
         name="list",
         help="List connections",
         
func=lazy_load_command("airflow.cli.commands.connection_command.connections_list"),
-        args=(ARG_OUTPUT, ARG_VERBOSE),
+        args=(ARG_OUTPUT, ARG_CONN_LIST_SHOW_VALUES, 
ARG_CONN_LIST_HIDE_SENSITIVE, ARG_VERBOSE),
     ),
     ActionCommand(
         name="add",
diff --git a/airflow-core/src/airflow/cli/commands/connection_command.py 
b/airflow-core/src/airflow/cli/commands/connection_command.py
index 7ce4e60b248..8911bc80b99 100644
--- a/airflow-core/src/airflow/cli/commands/connection_command.py
+++ b/airflow-core/src/airflow/cli/commands/connection_command.py
@@ -30,7 +30,7 @@ from sqlalchemy import select
 from sqlalchemy.orm import exc
 
 from airflow.cli.simple_table import AirflowConsole
-from airflow.cli.utils import is_stdout, print_export_output
+from airflow.cli.utils import SENSITIVE_PLACEHOLDER, is_stdout, 
print_export_output
 from airflow.configuration import conf
 from airflow.exceptions import AirflowNotFoundException
 from airflow.models import Connection
@@ -43,22 +43,84 @@ from airflow.utils.providers_configuration_loader import 
providers_configuration
 from airflow.utils.session import create_session
 
 
+def _mask_uri_credentials(uri: str) -> str:
+    """
+    Mask credentials in a URI while preserving structure.
+
+    Examples::
+
+        postgresql://user:pass@host:5432/db -> 
postgresql://***:***@host:5432/db
+        mysql://host/db -> mysql://host/db  (no credentials to mask)
+    """
+    if not uri:
+        return uri
+
+    try:
+        parsed = urlsplit(uri)
+        if not parsed.scheme:
+            return SENSITIVE_PLACEHOLDER
+
+        if "@" in parsed.netloc:
+            _creds, host_port = parsed.netloc.split("@", 1)
+            masked_netloc = 
f"{SENSITIVE_PLACEHOLDER}:{SENSITIVE_PLACEHOLDER}@{host_port}"
+            return urlunsplit((parsed.scheme, masked_netloc, parsed.path, 
parsed.query, parsed.fragment))
+        return uri
+    except Exception:
+        return SENSITIVE_PLACEHOLDER
+
+
+class ConnectionDisplayMapper:
+    """Mapper class for formatting connection data for CLI display."""
+
+    @staticmethod
+    def full_details(conn: Connection) -> dict[str, Any]:
+        """Return complete connection details including all fields."""
+        return {
+            "id": conn.id,
+            "conn_id": conn.conn_id,
+            "conn_type": conn.conn_type,
+            "description": conn.description,
+            "host": conn.host,
+            "schema": conn.schema,
+            "login": conn.login,
+            "password": conn.password,
+            "port": conn.port,
+            "is_encrypted": conn.is_encrypted,
+            "is_extra_encrypted": conn.is_encrypted,
+            "extra_dejson": conn.extra_dejson,
+            "get_uri": conn.get_uri(),
+        }
+
+    @staticmethod
+    def ids_only(conn: Connection) -> dict[str, Any]:
+        """Return only connection identifiers (no sensitive values). Used by 
list by default."""
+        return {
+            "conn_id": conn.conn_id,
+            "conn_type": conn.conn_type,
+        }
+
+    @staticmethod
+    def masked_sensitive(conn: Connection) -> dict[str, Any]:
+        """Return full connection structure with password, extra, and URI 
credentials masked."""
+        return {
+            "id": conn.id,
+            "conn_id": conn.conn_id,
+            "conn_type": conn.conn_type,
+            "description": conn.description,
+            "host": conn.host,
+            "schema": conn.schema,
+            "login": conn.login,
+            "password": SENSITIVE_PLACEHOLDER if conn.password else 
conn.password,
+            "port": conn.port,
+            "is_encrypted": conn.is_encrypted,
+            "is_extra_encrypted": conn.is_encrypted,
+            "extra_dejson": SENSITIVE_PLACEHOLDER if conn.extra_dejson else 
conn.extra_dejson,
+            "get_uri": _mask_uri_credentials(conn.get_uri()),
+        }
+
+
 def _connection_mapper(conn: Connection) -> dict[str, Any]:
-    return {
-        "id": conn.id,
-        "conn_id": conn.conn_id,
-        "conn_type": conn.conn_type,
-        "description": conn.description,
-        "host": conn.host,
-        "schema": conn.schema,
-        "login": conn.login,
-        "password": conn.password,
-        "port": conn.port,
-        "is_encrypted": conn.is_encrypted,
-        "is_extra_encrypted": conn.is_encrypted,
-        "extra_dejson": conn.extra_dejson,
-        "get_uri": conn.get_uri(),
-    }
+    return ConnectionDisplayMapper.full_details(conn)
 
 
 @suppress_logs_and_warning
@@ -81,7 +143,25 @@ def connections_get(args):
 @suppress_logs_and_warning
 @providers_configuration_loaded
 def connections_list(args):
-    """List all connections at the command line."""
+    """
+    List all connections at the command line.
+
+    By default only connection IDs and types are shown. Use --show-values to 
display
+    full connection details; use --hide-sensitive to mask passwords and URIs.
+    """
+    show_values = getattr(args, "show_values", False)
+    hide_sensitive = getattr(args, "hide_sensitive", False)
+
+    if hide_sensitive and not show_values:
+        raise SystemExit("--hide-sensitive can only be used with 
--show-values")
+
+    if not show_values:
+        mapper = ConnectionDisplayMapper.ids_only
+    elif hide_sensitive:
+        mapper = ConnectionDisplayMapper.masked_sensitive
+    else:
+        mapper = ConnectionDisplayMapper.full_details
+
     with create_session() as session:
         query = select(Connection)
         conns = session.scalars(query).all()
@@ -89,7 +169,7 @@ def connections_list(args):
         AirflowConsole().print_as(
             data=conns,
             output=args.output,
-            mapper=_connection_mapper,
+            mapper=mapper,
         )
 
 
diff --git a/airflow-core/src/airflow/cli/commands/variable_command.py 
b/airflow-core/src/airflow/cli/commands/variable_command.py
index e20f549901b..85166cb6e79 100644
--- a/airflow-core/src/airflow/cli/commands/variable_command.py
+++ b/airflow-core/src/airflow/cli/commands/variable_command.py
@@ -25,7 +25,7 @@ import os
 from sqlalchemy import select
 
 from airflow.cli.simple_table import AirflowConsole
-from airflow.cli.utils import print_export_output
+from airflow.cli.utils import SENSITIVE_PLACEHOLDER, print_export_output
 from airflow.exceptions import (
     AirflowFileParseException,
     AirflowUnsupportedFileTypeException,
@@ -39,13 +39,53 @@ from airflow.utils.providers_configuration_loader import 
providers_configuration
 from airflow.utils.session import create_session, provide_session
 
 
+class VariableDisplayMapper:
+    """Mapper class for formatting variable data for CLI display."""
+
+    @staticmethod
+    def keys_only(var) -> dict[str, str]:
+        """Return only variable keys. Accepts Variable model or dict with 
'key'."""
+        key = var.key if hasattr(var, "key") else var["key"]
+        return {"key": key}
+
+    @staticmethod
+    def with_values(var, hide_sensitive: bool = False) -> dict[str, str]:
+        """Return variable with value, optionally masked."""
+        key = var.key if hasattr(var, "key") else var["key"]
+        raw = var.val if hasattr(var, "val") else var.get("val", 
var.get("_val"))
+        val = "" if raw is None else str(raw)
+        if hide_sensitive:
+            val = SENSITIVE_PLACEHOLDER
+        return {"key": key, "val": val}
+
+
 @suppress_logs_and_warning
 @providers_configuration_loaded
 def variables_list(args):
-    """Display all the variables."""
+    """
+    Display all the variables.
+
+    By default only variable keys are shown. Use --show-values to display
+    values; use --hide-sensitive to mask all variable values (since individual
+    variables cannot be automatically classified as sensitive or not).
+    """
+    show_values = getattr(args, "show_values", False)
+    hide_sensitive = getattr(args, "hide_sensitive", False)
+
+    if hide_sensitive and not show_values:
+        raise SystemExit("--hide-sensitive can only be used with 
--show-values")
+
+    def _mapper(var):
+        return VariableDisplayMapper.with_values(var, hide_sensitive)
+
     with create_session() as session:
-        variables = session.scalars(select(Variable)).all()
-    AirflowConsole().print_as(data=variables, output=args.output, 
mapper=lambda x: {"key": x.key})
+        if show_values:
+            variables = session.scalars(select(Variable)).all()
+            AirflowConsole().print_as(data=variables, output=args.output, 
mapper=_mapper)
+        else:
+            keys = session.scalars(select(Variable.key).distinct()).all()
+            variables = [{"key": key} for key in keys]
+            AirflowConsole().print_as(data=variables, output=args.output, 
mapper=None)
 
 
 @suppress_logs_and_warning
diff --git a/airflow-core/src/airflow/cli/utils.py 
b/airflow-core/src/airflow/cli/utils.py
index b221521e01d..870f045071b 100644
--- a/airflow-core/src/airflow/cli/utils.py
+++ b/airflow-core/src/airflow/cli/utils.py
@@ -20,6 +20,9 @@ from __future__ import annotations
 import sys
 from typing import TYPE_CHECKING
 
+# Placeholder for masking sensitive values in CLI output
+SENSITIVE_PLACEHOLDER = "***"
+
 if TYPE_CHECKING:
     import datetime
     from collections.abc import Collection
diff --git a/airflow-core/tests/unit/cli/commands/test_connection_command.py 
b/airflow-core/tests/unit/cli/commands/test_connection_command.py
index 8b22a570eb9..c62dfb4c5b3 100644
--- a/airflow-core/tests/unit/cli/commands/test_connection_command.py
+++ b/airflow-core/tests/unit/cli/commands/test_connection_command.py
@@ -29,6 +29,7 @@ from sqlalchemy import select
 
 from airflow.cli import cli_config, cli_parser
 from airflow.cli.commands import connection_command
+from airflow.cli.commands.connection_command import _mask_uri_credentials
 from airflow.exceptions import AirflowException
 from airflow.models import Connection
 from airflow.utils.db import merge_conn
@@ -95,6 +96,81 @@ class TestCliListConnections:
             assert conn_type in stdout
             assert conn_id in stdout
 
+    def test_cli_connections_list_default_hides_sensitive_values(self):
+        """By default list shows only conn_id and conn_type, not passwords or 
URI."""
+        args = self.parser.parse_args(["connections", "list", "--output", 
"json"])
+        with redirect_stdout(StringIO()) as stdout_io:
+            connection_command.connections_list(args)
+            stdout = stdout_io.getvalue()
+        # Should not contain full URI or password fields
+        assert "get_uri" not in stdout
+        assert "password" not in stdout
+        assert "conn_id" in stdout
+        assert "conn_type" in stdout
+
+    def test_cli_connections_list_show_values_shows_full_details(self):
+        """With --show-values, list includes connection details."""
+        args = self.parser.parse_args(["connections", "list", "--output", 
"json", "--show-values"])
+        with redirect_stdout(StringIO()) as stdout_io:
+            connection_command.connections_list(args)
+            stdout = stdout_io.getvalue()
+        assert "get_uri" in stdout
+        assert "conn_id" in stdout
+
+    def 
test_cli_connections_list_show_values_hide_sensitive_masks_values(self):
+        """With --show-values --hide-sensitive, sensitive fields are masked."""
+        args = self.parser.parse_args(
+            [
+                "connections",
+                "list",
+                "--output",
+                "json",
+                "--show-values",
+                "--hide-sensitive",
+            ]
+        )
+        with redirect_stdout(StringIO()) as stdout_io:
+            connection_command.connections_list(args)
+            stdout = stdout_io.getvalue()
+        assert "***" in stdout
+        # Password should be masked
+        assert '"password": "***"' in stdout
+        # get_uri should be selectively masked (credentials only)
+        # Note: Default connections may not have credentials, so we check for 
***
+        if "***" in stdout:
+            # If there are masked credentials, they should be in ***:*** 
format in get_uri
+            assert '"get_uri":' in stdout
+
+    def 
test_cli_connections_list_hide_sensitive_without_show_values_fails(self):
+        """--hide-sensitive without --show-values should fail."""
+        args = self.parser.parse_args(["connections", "list", 
"--hide-sensitive"])
+        with pytest.raises(SystemExit, match="--hide-sensitive can only be 
used with --show-values"):
+            connection_command.connections_list(args)
+
+
+class TestUriMasking:
+    """Test URI credential masking functionality."""
+
+    @pytest.mark.parametrize(
+        ("uri", "expected"),
+        [
+            # URIs with credentials
+            ("postgresql://user:pass@host:5432/db", 
"postgresql://***:***@host:5432/db"),
+            ("mysql://admin:secret@localhost:3306/test", 
"mysql://***:***@localhost:3306/test"),
+            ("http://api:[email protected]:8080/v1";, 
"http://***:***@api.example.com:8080/v1";),
+            # URIs without credentials
+            ("sqlite:///tmp/test.db", "sqlite:///tmp/test.db"),
+            ("filesystem://", "filesystem://"),
+            ("redis://localhost:6379/0", "redis://localhost:6379/0"),
+            # Edge cases
+            ("", ""),
+            ("invalid-uri", "***"),  # Falls back to full masking on parse 
error
+        ],
+    )
+    def test_mask_uri_credentials(self, uri, expected):
+        result = _mask_uri_credentials(uri)
+        assert result == expected
+
 
 class TestCliExportConnections:
     parser = cli_parser.get_parser()
diff --git a/airflow-core/tests/unit/cli/commands/test_variable_command.py 
b/airflow-core/tests/unit/cli/commands/test_variable_command.py
index 4488b0e758d..21d2fb66822 100644
--- a/airflow-core/tests/unit/cli/commands/test_variable_command.py
+++ b/airflow-core/tests/unit/cli/commands/test_variable_command.py
@@ -19,6 +19,8 @@ from __future__ import annotations
 
 import json
 import os
+from contextlib import redirect_stdout
+from io import StringIO
 
 import pytest
 import yaml
@@ -232,6 +234,96 @@ class TestCliVariables:
         # Test command is received
         variable_command.variables_list(self.parser.parse_args(["variables", 
"list"]))
 
+    def test_variables_list_show_values(self):
+        """Test variables list with --show-values flag shows actual values."""
+        # Create test variables
+        Variable.set("test_key1", "test_value1")
+        Variable.set("test_key2", "test_value2")
+
+        args = self.parser.parse_args(["variables", "list", "--output", 
"json", "--show-values"])
+        with redirect_stdout(StringIO()) as stdout_io:
+            variable_command.variables_list(args)
+            output = stdout_io.getvalue()
+
+        # Parse JSON output and verify values are shown
+        data = json.loads(output)
+        assert len(data) >= 2
+        key_value_map = {item["key"]: item["val"] for item in data}
+        assert "test_value1" in key_value_map["test_key1"]
+        assert "test_value2" in key_value_map["test_key2"]
+
+    def test_variables_list_hide_sensitive(self):
+        """Test variables list with --hide-sensitive masks all values."""
+        # Create test variables
+        Variable.set("test_key1", "test_value1")
+        Variable.set("test_key2", "test_value2")
+
+        args = self.parser.parse_args(
+            ["variables", "list", "--output", "json", "--show-values", 
"--hide-sensitive"]
+        )
+        with redirect_stdout(StringIO()) as stdout_io:
+            variable_command.variables_list(args)
+            output = stdout_io.getvalue()
+
+        # Parse JSON output and verify values are masked
+        data = json.loads(output)
+        assert len(data) >= 2
+        for item in data:
+            if "test_key" in item["key"]:
+                assert item["val"] == "***"
+
+    def test_variables_list_hide_sensitive_without_show_values_fails(self):
+        """--hide-sensitive without --show-values should fail."""
+        args = self.parser.parse_args(["variables", "list", 
"--hide-sensitive"])
+        with pytest.raises(SystemExit, match="--hide-sensitive can only be 
used with --show-values"):
+            variable_command.variables_list(args)
+
+    def test_variables_list_default_hides_values(self):
+        """By default, variables list should only show keys, not values."""
+        Variable.set("test_key1", "test_value1")
+        Variable.set("test_key2", "test_value2")
+
+        args = self.parser.parse_args(["variables", "list", "--output", 
"json"])
+        with redirect_stdout(StringIO()) as stdout_io:
+            variable_command.variables_list(args)
+            output = stdout_io.getvalue()
+
+        data = json.loads(output)
+        assert len(data) >= 2
+        for item in data:
+            if "test_key" in item["key"]:
+                assert "val" not in item
+
+    def test_variables_list_edge_cases(self):
+        """Test variables list with None and empty values."""
+        Variable.set("empty_var", "")
+        Variable.set("none_var", None)
+        Variable.set("normal_var", "normal_value")
+
+        args = self.parser.parse_args(["variables", "list", "--output", 
"json", "--show-values"])
+        with redirect_stdout(StringIO()) as stdout_io:
+            variable_command.variables_list(args)
+            output = stdout_io.getvalue()
+
+        data = json.loads(output)
+        key_value_map = {item["key"]: item["val"] for item in data}
+
+        assert key_value_map["empty_var"] == ""
+        assert key_value_map["none_var"] == "None"
+        assert key_value_map["normal_var"] == "normal_value"
+
+        args = self.parser.parse_args(
+            ["variables", "list", "--output", "json", "--show-values", 
"--hide-sensitive"]
+        )
+        with redirect_stdout(StringIO()) as stdout_io:
+            variable_command.variables_list(args)
+            output = stdout_io.getvalue()
+
+        data = json.loads(output)
+        for item in data:
+            if item["key"] in ["empty_var", "none_var", "normal_var"]:
+                assert item["val"] == "***"
+
     def test_variables_delete(self):
         """Test variable_delete command"""
         variable_command.variables_set(self.parser.parse_args(["variables", 
"set", "foo", "bar"]))

Reply via email to