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"]))