Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Add `-m`/`--mask` flag to `dotenv list` to mask displayed values whose key looks sensitive (substring match against a keyword list such as `KEY`, `SECRET`, `TOKEN`, `PASSWORD`), showing the first and last 2 characters for values longer than 4 characters and `****` otherwise; non-sensitive keys and unset values are left unmasked

### Fixed

- An unquoted empty value followed by an inline comment (e.g. `KEY= # comment`) is now parsed as an empty string instead of the comment text by [@Noethix55555] in [#663]
Expand Down
58 changes: 57 additions & 1 deletion src/dotenv/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,49 @@ def stream_file(path: os.PathLike) -> Iterator[IO[str]]:
sys.exit(2)


_SENSITIVE_KEY_SUBSTRINGS = (
"KEY",
"SECRET",
"TOKEN",
"PASSWORD",
"PASSWD",
"PWD",
"CREDENTIAL",
"AUTH",
"PRIVATE",
"ACCESS",
"CERT",
"DSN",
"CONNECTION_STRING",
"CONN_STRING",
)


def _is_sensitive_key(key: str) -> bool:
"""
Return whether a key looks like it holds a sensitive value.

Does a case-insensitive substring match against a fixed list of
sensitivity keywords (e.g. "MY_API_KEY" matches on "KEY", "DB_PASSWORD"
matches on "PASSWORD").
"""
upper_key = key.upper()
return any(keyword in upper_key for keyword in _SENSITIVE_KEY_SUBSTRINGS)


def _mask_value(value: str) -> str:
"""
Mask a sensitive value for display, leaving only a hint of its content.

Values longer than 4 characters keep their first and last 2 characters,
e.g. "MY_SECRET_KEY" becomes "MY****EY". Values of 4 characters or fewer
are fully replaced with "****".
"""
if len(value) > 4:
return f"{value[:2]}****{value[-2:]}"
return "****"


@cli.command(name="list")
@click.pass_context
@click.option(
Expand All @@ -90,13 +133,26 @@ def stream_file(path: os.PathLike) -> Iterator[IO[str]]:
help="The format in which to display the list. Default format is simple, "
"which displays name=value without quotes.",
)
def list_values(ctx: click.Context, output_format: str) -> None:
@click.option(
"-m",
"--mask",
is_flag=True,
default=False,
help="Mask values whose key looks sensitive, showing only a hint of their content.",
)
def list_values(ctx: click.Context, output_format: str, mask: bool) -> None:
"""Display all the stored key/value."""
file = ctx.obj["FILE"]

with stream_file(file) as stream:
values = dotenv_values(stream=stream)

if mask:
values = {
k: (_mask_value(v) if v is not None and _is_sensitive_key(k) else v)
for k, v in values.items()
}

if output_format == "json":
click.echo(json.dumps(values, indent=2, sort_keys=True))
else:
Expand Down
104 changes: 104 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,110 @@ def test_list(
assert (result.exit_code, result.output) == (0, expected)


@pytest.mark.parametrize(
"output_format,content,expected",
(
(None, "API_KEY=abcdefgh", """API_KEY=ab****gh\n"""),
("simple", "API_KEY=abcdefgh", """API_KEY=ab****gh\n"""),
("simple", "API_KEY=abcd", """API_KEY=****\n"""),
("simple", "API_KEY=ab", """API_KEY=****\n"""),
("simple", "API_KEY", ""),
("json", "API_KEY=abcdefgh", """{\n "API_KEY": "ab****gh"\n}\n"""),
("json", "API_KEY=abcd", """{\n "API_KEY": "****"\n}\n"""),
("json", "API_KEY", """{\n "API_KEY": null\n}\n"""),
("shell", "API_KEY=abcdefgh", "API_KEY='ab****gh'\n"),
("export", "API_KEY=abcdefgh", "export API_KEY='ab****gh'\n"),
),
)
def test_list_mask(
cli, dotenv_path, output_format: Optional[str], content: str, expected: str
):
dotenv_path.write_text(content + "\n")

args = ["--file", dotenv_path, "list", "--mask"]
if output_format is not None:
args.extend(["--format", output_format])

result = cli.invoke(dotenv_cli, args)

assert (result.exit_code, result.output) == (0, expected)


def test_list_mask_non_sensitive_key_unmasked(cli, dotenv_path):
"""--mask must not touch a value whose key doesn't look sensitive."""
dotenv_path.write_text("PORT=8080\n")

result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "list", "--mask"])

assert (result.exit_code, result.output) == (0, "PORT=8080\n")


def test_list_mask_short_flag(cli, dotenv_path):
"""The -m alias must behave the same as --mask."""
dotenv_path.write_text("API_KEY=abcdefgh\n")

result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "list", "-m"])

assert (result.exit_code, result.output) == (0, "API_KEY=ab****gh\n")


def test_list_mask_empty_value(cli, dotenv_path):
"""An explicit empty value (`KEY=`) is distinct from a bare key (`KEY`,
which parses to None and is left unmasked/unprinted). It should mask to
the <=4-character bucket."""
dotenv_path.write_text("API_KEY=\n")

result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "list", "--mask"])

assert (result.exit_code, result.output) == (0, "API_KEY=****\n")


def test_list_mask_five_char_boundary(cli, dotenv_path):
"""A 5-character value is the first to cross into the partial-reveal
(>4 characters) branch."""
dotenv_path.write_text("API_KEY=abcde\n")

result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "list", "--mask"])

assert (result.exit_code, result.output) == (0, "API_KEY=ab****de\n")


def test_list_mask_custom_file(cli, tmp_path):
"""--mask works with an explicit, non-default --file path."""
custom_path = tmp_path / "custom.env"
custom_path.write_text("API_KEY=abcdefgh\n")

result = cli.invoke(dotenv_cli, ["--file", str(custom_path), "list", "--mask"])

assert (result.exit_code, result.output) == (0, "API_KEY=ab****gh\n")


def test_list_mask_mixed_sensitive_and_non_sensitive(cli, dotenv_path):
"""Per-key gating is visible within a single result: only the sensitive
key is redacted, the non-sensitive key prints unchanged."""
dotenv_path.write_text("API_KEY=abcdefgh\nPORT=8080\n")

result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "list", "--mask"])

assert (result.exit_code, result.output) == (
0,
"API_KEY=ab****gh\nPORT=8080\n",
)


def test_list_mask_mixed_sensitive_and_non_sensitive_json(cli, dotenv_path):
dotenv_path.write_text("API_KEY=abcdefgh\nPORT=8080\n")

result = cli.invoke(
dotenv_cli, ["--file", dotenv_path, "list", "--mask", "--format", "json"]
)

assert (result.exit_code, result.output) == (
0,
"""{\n "API_KEY": "ab****gh",\n "PORT": "8080"\n}\n""",
)


def test_list_non_existent_file(cli):
result = cli.invoke(dotenv_cli, ["--file", "nx_file", "list"])

Expand Down