diff --git a/CHANGELOG.md b/CHANGELOG.md index 00f08f58..d7c41384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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] diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 79613e28..550e439d 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -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( @@ -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: diff --git a/tests/test_cli.py b/tests/test_cli.py index d4e3ad4d..b8fea188 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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"])