diff --git a/esphome/__main__.py b/esphome/__main__.py index 7eef646ebb..d62fa1bafd 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1412,12 +1412,47 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: if not CORE.verbose: config = strip_default_ids(config) output = yaml_util.dump(config, args.show_secrets) + if not args.show_secrets: + output = _redact_with_legacy_fallback(output) if not CORE.quiet: safe_print(output) _LOGGER.info("Configuration is valid!") return 0 +# Legacy substring redaction fallback. Catches sensitive-looking fields that +# aren't yet tagged with ``cv.sensitive(...)`` so config dumps don't regress +# for unmigrated/third-party schemas. Each match also logs a one-time +# deprecation warning naming the field; this fallback is slated for removal +# in 2026.12.0 once canonical sensitive fields are migrated. +# +# The negative lookahead ``(?!\\033\[8m)`` skips values already wrapped by the +# SensitiveStr representer, so explicit tagging silences the warning. +_LEGACY_REDACTION_RE = re.compile( + r"(?P\w*(?:password|key|psk|ssid)\w*)\: (?!\\033\[8m)(?P.+)" +) +_LEGACY_REDACTION_REMOVAL = "2026.12.0" + + +def _redact_with_legacy_fallback(output: str) -> str: + unmarked: set[str] = set() + + def _replace(m: re.Match[str]) -> str: + unmarked.add(m.group("key")) + return f"{m.group('key')}: \\033[8m{m.group('val')}\\033[28m" + + output = _LEGACY_REDACTION_RE.sub(_replace, output) + for key in sorted(unmarked): + _LOGGER.warning( + "Field '%s' is being redacted by a legacy substring heuristic. " + "Mark this field's schema validator with cv.sensitive(...) for " + "deterministic redaction; the heuristic will be removed in %s.", + key, + _LEGACY_REDACTION_REMOVAL, + ) + return output + + def command_config_hash(args: ArgsProtocol, config: ConfigType) -> int | None: # generating code might modify config, so it must be done in order to generate # a hash that will match what was generated when compiling and then running diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index f6b6d0b05f..a103f14da5 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -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, @@ -340,6 +341,52 @@ 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_choose_upload_log_host_with_string_default() -> None: """Test with a single string default device.""" setup_core()