[core] Sensitive redaction via yaml_util representer (#16690)

This commit is contained in:
J. Nick Koston
2026-05-27 09:20:50 -05:00
committed by GitHub
parent 3cc875c40b
commit 21e548f1d7
7 changed files with 306 additions and 10 deletions
+131
View File
@@ -22,6 +22,7 @@ from esphome.__main__ import (
Purpose,
_get_configured_xtal_freq,
_make_crystal_freq_callback,
_redact_with_legacy_fallback,
_resolve_network_devices,
_validate_bootloader_binary,
_validate_partition_table_binary,
@@ -29,6 +30,7 @@ from esphome.__main__ import (
command_analyze_memory,
command_bundle,
command_clean_all,
command_config,
command_config_hash,
command_rename,
command_run,
@@ -340,6 +342,135 @@ def mock_ram_strings_analyzer() -> Generator[Mock]:
yield mock_class
def test_redact_with_legacy_fallback__wraps_unmarked_field(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Unmarked sensitive-shaped fields are redacted; a deprecation warning
is emitted naming the field."""
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
out = _redact_with_legacy_fallback("password: hunter2\n")
assert "password: \\033[8mhunter2\\033[28m" in out
assert any(
"password" in rec.message and "cv.sensitive" in rec.message
for rec in caplog.records
)
def test_redact_with_legacy_fallback__skips_already_wrapped(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Values already wrapped by the SensitiveStr representer don't trigger
the heuristic or the warning."""
wrapped = "password: \\033[8mhunter2\\033[28m\n"
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
out = _redact_with_legacy_fallback(wrapped)
assert out == wrapped
assert not any("legacy substring" in rec.message for rec in caplog.records)
def test_redact_with_legacy_fallback__captures_full_field_name(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The warning names the actual field, not just the matched fragment."""
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
_redact_with_legacy_fallback("encryption_key: abc\n")
assert any("encryption_key" in rec.message for rec in caplog.records)
def test_redact_with_legacy_fallback__deduplicates_warnings(
caplog: pytest.LogCaptureFixture,
) -> None:
"""One warning per unique field name even if it appears many times."""
text = "password: a\npassword: b\npassword: c\n"
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
_redact_with_legacy_fallback(text)
password_warnings = [rec for rec in caplog.records if "'password'" in rec.message]
assert len(password_warnings) == 1
def test_redact_with_legacy_fallback__skips_lambda_values(
caplog: pytest.LogCaptureFixture,
) -> None:
"""``!lambda`` first line is structural, body is unreachable by a
single-line regex anyway, and tagged fields shouldn't trigger a warning."""
text = ' ssid: !lambda |-\n return "x";\n'
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
out = _redact_with_legacy_fallback(text)
assert out == text
assert not any("legacy substring" in rec.message for rec in caplog.records)
def test_redact_with_legacy_fallback__skips_secret_references(
caplog: pytest.LogCaptureFixture,
) -> None:
"""``!secret name`` is the dumper's user-friendly representation; the
name isn't the secret, so wrapping it would clobber the round-trip."""
text = " password: !secret wifi_password\n"
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
out = _redact_with_legacy_fallback(text)
assert out == text
assert not any("legacy substring" in rec.message for rec in caplog.records)
def test_redact_with_legacy_fallback__does_not_match_fragment_in_middle(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Fragment must end the field name; embedded matches like
``key_value_pair`` are unrelated to a sensitive key and must not be
redacted (matching the prior regex's scope)."""
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
out = _redact_with_legacy_fallback("key_value_pair: abc\n")
assert "\\033[8m" not in out
assert not any("legacy substring" in rec.message for rec in caplog.records)
def test_redact_with_legacy_fallback__does_not_match_fragment_as_suffix(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Fragment must start the name or follow ``_``; ``monkey:`` shouldn't
fire a 'legacy heuristic' warning because there's no sensitive field
here — the user has nothing to migrate."""
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
out = _redact_with_legacy_fallback("monkey: 1234\n")
assert "\\033[8m" not in out
assert not any("legacy substring" in rec.message for rec in caplog.records)
def test_command_config__invokes_legacy_fallback_when_redacting(
tmp_path: Path, capfd: CaptureFixture[str]
) -> None:
"""``command_config`` runs the legacy fallback on the dumped output when
``--show-secrets`` is off. Cover the wiring (not just the helper).
"""
setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}})
args = MockArgs()
args.show_secrets = False
result = command_config(args, {"wifi": {"password": "hunter2"}})
assert result == 0
output = capfd.readouterr().out
assert "\\033[8mhunter2\\033[28m" in output
def test_command_config__show_secrets_skips_redaction(
tmp_path: Path, capfd: CaptureFixture[str]
) -> None:
"""With ``--show-secrets`` the helper isn't invoked and the value
renders raw.
"""
setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}})
args = MockArgs()
args.show_secrets = True
result = command_config(args, {"wifi": {"password": "hunter2"}})
assert result == 0
output = capfd.readouterr().out
assert "hunter2" in output
assert "\\033[8m" not in output
def test_choose_upload_log_host_with_string_default() -> None:
"""Test with a single string default device."""
setup_core()