mirror of
https://github.com/esphome/esphome.git
synced 2026-09-12 07:47:33 +00:00
[core] Keep legacy redaction regex as deprecation fallback
Restore the substring regex as a second pass in command_config so sensitive-shaped fields that haven't been tagged with cv.sensitive(...) yet are still redacted. Each unique unmarked field name caught by the heuristic emits a one-time deprecation warning naming the field and the fix; the fallback itself is slated for removal in 2026.12.0.
This commit is contained in:
@@ -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<key>\w*(?:password|key|psk|ssid)\w*)\: (?!\\033\[8m)(?P<val>.+)"
|
||||
)
|
||||
_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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user