[core] Sensitive redaction via yaml_util representer

cv.sensitive(...) now returns a SensitiveStr (thin str subclass) so the
tag travels with the validated value. yaml_util.dump constructs a
per-call ESPHomeDumper subclass with a class-attribute redaction flag;
the PyYAML representer for SensitiveStr renders values wrapped in
literal \\033[8m...\\033[28m text when show_secrets is False and raw
when True. The post-dump regex in command_config is deleted.

Also tags wifi.ssid sites with cv.sensitive so SSID coverage isn't lost
when the regex (which matched 'ssid:' via substring) goes away.

No module-level mutable state; the per-call subclass keeps each dump
invocation self-contained and thread-safe by construction.
This commit is contained in:
J. Nick Koston
2026-05-26 21:49:31 -05:00
parent 8d19c55be2
commit 49b9d9313e
6 changed files with 148 additions and 11 deletions
@@ -145,6 +145,46 @@ def test_sensitive__custom_inner_delegates_validation() -> None:
validator(123)
def test_sensitive__wraps_string_result_in_sensitive_str() -> None:
from esphome.yaml_util import SensitiveStr
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:
from esphome.yaml_util import SensitiveStr
# 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()
+55
View File
@@ -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