mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 13:09:12 +00:00
[core] Sensitive redaction via yaml_util representer (#16690)
This commit is contained in:
@@ -27,6 +27,7 @@ from esphome.const import (
|
||||
SCHEDULER_DONT_RUN,
|
||||
)
|
||||
from esphome.core import CORE, HexInt, Lambda
|
||||
from esphome.yaml_util import SensitiveStr
|
||||
|
||||
|
||||
def test_check_not_templatable__invalid():
|
||||
@@ -145,6 +146,42 @@ def test_sensitive__custom_inner_delegates_validation() -> None:
|
||||
validator(123)
|
||||
|
||||
|
||||
def test_sensitive__wraps_string_result_in_sensitive_str() -> None:
|
||||
validator = config_validation.sensitive()
|
||||
result = validator("hunter2")
|
||||
|
||||
assert isinstance(result, SensitiveStr)
|
||||
assert isinstance(result, str)
|
||||
assert result == "hunter2"
|
||||
|
||||
|
||||
def test_sensitive__does_not_double_tag_already_sensitive() -> None:
|
||||
# If the inner validator already returns a SensitiveStr (e.g., nested
|
||||
# cv.sensitive wrappers), re-tagging is a no-op rather than a new
|
||||
# SensitiveStr around the same value.
|
||||
pre_tagged = SensitiveStr("hunter2")
|
||||
|
||||
def inner(_value):
|
||||
return pre_tagged
|
||||
|
||||
validator = config_validation.sensitive(inner)
|
||||
result = validator("anything")
|
||||
|
||||
assert result is pre_tagged
|
||||
|
||||
|
||||
def test_sensitive__non_string_result_passes_through() -> None:
|
||||
# If an inner validator returns something other than a string (e.g., a
|
||||
# Lambda template), the sensitive wrapper must not coerce it.
|
||||
sentinel = object()
|
||||
|
||||
def inner(_value):
|
||||
return sentinel
|
||||
|
||||
validator = config_validation.sensitive(inner)
|
||||
assert validator("anything") is sentinel
|
||||
|
||||
|
||||
def test_sensitive__is_detectable_via_isinstance() -> None:
|
||||
validator = config_validation.sensitive()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -15,6 +15,7 @@ from esphome.yaml_util import (
|
||||
DiscoveredYamlFiles,
|
||||
ESPHomeDataBase,
|
||||
ESPLiteralValue,
|
||||
SensitiveStr,
|
||||
discover_user_yaml_files,
|
||||
force_load_include_files,
|
||||
format_path,
|
||||
@@ -1340,3 +1341,57 @@ def test_frontmatter_included_file_stored(tmp_path: Path) -> None:
|
||||
assert main.resolve() not in core.CORE.frontmatter
|
||||
# Included file's frontmatter is captured
|
||||
assert core.CORE.frontmatter[inc.resolve()]["child_meta"] == "hello"
|
||||
|
||||
|
||||
def test_sensitive_str__is_a_str_subclass() -> None:
|
||||
value = SensitiveStr("hunter2")
|
||||
assert isinstance(value, str)
|
||||
assert value == "hunter2"
|
||||
|
||||
|
||||
def test_dump__redacts_sensitive_str_by_default() -> None:
|
||||
out = yaml_util.dump({"password": SensitiveStr("hunter2")})
|
||||
assert "\\033[8mhunter2\\033[28m" in out
|
||||
assert "hunter2" not in out.replace(
|
||||
"\\033[8mhunter2\\033[28m", ""
|
||||
) # the raw value is only present inside the wrap
|
||||
|
||||
|
||||
def test_dump__show_secrets_emits_sensitive_str_raw() -> None:
|
||||
out = yaml_util.dump({"password": SensitiveStr("hunter2")}, show_secrets=True)
|
||||
assert "hunter2" in out
|
||||
assert "\\033[8m" not in out
|
||||
assert "\\033[28m" not in out
|
||||
|
||||
|
||||
def test_dump__plain_str_is_not_redacted() -> None:
|
||||
out = yaml_util.dump({"hostname": "myserver"})
|
||||
assert "myserver" in out
|
||||
assert "\\033[8m" not in out
|
||||
|
||||
|
||||
def test_dump__secret_reference_wins_over_redaction() -> None:
|
||||
# If the value also has an entry in _SECRET_VALUES (i.e., it was loaded
|
||||
# via !secret), the dump should render it as !secret <name>, not as a
|
||||
# redacted scalar. SensitiveStr layered on top must not change that.
|
||||
value = SensitiveStr("hunter2")
|
||||
yaml_util._SECRET_VALUES[str(value)] = "my_secret_name"
|
||||
try:
|
||||
out = yaml_util.dump({"password": value})
|
||||
assert "!secret" in out
|
||||
assert "my_secret_name" in out
|
||||
assert "\\033[8m" not in out
|
||||
finally:
|
||||
yaml_util._SECRET_VALUES.clear()
|
||||
|
||||
|
||||
def test_dump__redaction_flag_does_not_leak_between_calls() -> None:
|
||||
# Per-call _Dumper subclass means show_secrets in one call doesn't
|
||||
# affect another. Run them in both orders to catch any leakage.
|
||||
redacted = yaml_util.dump({"password": SensitiveStr("hunter2")})
|
||||
raw = yaml_util.dump({"password": SensitiveStr("hunter2")}, show_secrets=True)
|
||||
redacted_again = yaml_util.dump({"password": SensitiveStr("hunter2")})
|
||||
|
||||
assert "\\033[8m" in redacted
|
||||
assert "\\033[8m" not in raw
|
||||
assert "\\033[8m" in redacted_again
|
||||
|
||||
Reference in New Issue
Block a user