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

This commit is contained in:
J. Nick Koston authored and GitHub committed 2026-05-27 09:20:50 -05:00
1 parent 3cc875c40b
commit 21e548f1d7
7 files changed
+306 -10

No files matched your search

+34 -4
View File
@@ -1412,17 +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)
# add the console decoration so the front-end can hide the secrets
if not args.show_secrets:
output = re.sub(
r"(password|key|psk|ssid)\: (.+)", r"\1: \\033[8m\2\\033[28m", output
)
output = _redact_with_legacy_fallback(output)
if not CORE.quiet:
safe_print(output)
_LOGGER.info("Configuration is valid!")
return 0
# Legacy substring redaction fallback for unmigrated schemas; removed in
# 2026.12.0 once canonical sensitive fields are tagged. The lookahead skips
# values that already render themselves: ``\033[8m`` (SensitiveStr wrap),
# ``!secret`` (preserves the user-friendly tag), ``!lambda`` (multi-line
# block; first line is structural). The fragment must either start the
# field name or follow ``_`` so the warning names a real field; this avoids
# false positives like ``monkey:`` matching the ``key`` fragment.
_LEGACY_REDACTION_RE = re.compile(
r"(?P<key>\b(?:\w+_)?(?:password|key|psk|ssid))\: "
r"(?!\\033\[8m|!secret\b|!lambda\b)(?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
+3 -3
View File
@@ -271,7 +271,7 @@ EAP_AUTH_SCHEMA = cv.All(
WIFI_NETWORK_BASE = cv.Schema(
{
cv.GenerateID(): cv.declare_id(WiFiAP),
cv.Optional(CONF_SSID): cv.ssid,
cv.Optional(CONF_SSID): cv.sensitive(cv.ssid),
cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password),
cv.Optional(CONF_CHANNEL): validate_channel,
cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA,
@@ -434,7 +434,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_NETWORKS): cv.All(
cv.ensure_list(WIFI_NETWORK_STA), cv.Length(max=MAX_WIFI_NETWORKS)
),
cv.Optional(CONF_SSID): cv.ssid,
cv.Optional(CONF_SSID): cv.sensitive(cv.ssid),
cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password),
cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA,
cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA,
@@ -850,7 +850,7 @@ async def final_step():
WiFiConfigureAction,
cv.Schema(
{
cv.Required(CONF_SSID): cv.templatable(cv.ssid),
cv.Required(CONF_SSID): cv.sensitive(cv.templatable(cv.ssid)),
cv.Required(CONF_PASSWORD): cv.sensitive(cv.templatable(validate_password)),
cv.Optional(CONF_SAVE, default=True): cv.templatable(cv.boolean),
cv.Optional(CONF_TIMEOUT, default="30000ms"): cv.templatable(
+8 -2
View File
@@ -101,7 +101,7 @@ from esphome.schema_extractors import (
)
from esphome.util import parse_esphome_version
from esphome.voluptuous_schema import _Schema
from esphome.yaml_util import make_data_base
from esphome.yaml_util import SensitiveStr, make_data_base
_LOGGER = logging.getLogger(__name__)
@@ -514,7 +514,13 @@ class SensitiveValidator:
self.inner = inner
def __call__(self, value: typing.Any) -> typing.Any:
return self.inner(value)
validated = self.inner(value)
# Tag string results so yaml_util.dump can mask them. Non-string
# results pass through unchanged; already-tagged values are not
# re-wrapped to keep nested cv.sensitive applications idempotent.
if isinstance(validated, str) and not isinstance(validated, SensitiveStr):
return SensitiveStr(validated)
return validated
def __repr__(self) -> str:
# Mirror the inner validator's repr so ``build_language_schema``'s
+38 -1
View File
@@ -52,6 +52,16 @@ _load_listeners: list[Callable[[Path], None]] = []
DocumentPath = list[str | int]
class SensitiveStr(str):
"""Marker subclass for validated strings that should be masked in
user-visible YAML output. ``cv.sensitive`` wraps validated values in this
type so ``dump()`` can render them with ANSI conceal codes without
needing a post-process regex.
"""
__slots__ = ()
@contextmanager
def track_yaml_loads() -> Generator[list[Path]]:
"""Context manager that records every file loaded by the YAML loader.
@@ -808,11 +818,18 @@ def dump(dict_, show_secrets=False, sort_keys=False):
if show_secrets:
_SECRET_VALUES.clear()
_SECRET_CACHE.clear()
# Per-call subclass so the redaction flag doesn't leak across calls.
# (``_SECRET_VALUES`` / ``_SECRET_CACHE`` remain module globals; YAML
# processing is single-threaded today, so this isolates only the flag.)
class _Dumper(ESPHomeDumper):
_redact_sensitive = not show_secrets
return yaml.dump(
dict_,
default_flow_style=False,
allow_unicode=True,
Dumper=ESPHomeDumper,
Dumper=_Dumper,
sort_keys=sort_keys,
)
@@ -958,6 +975,10 @@ def format_path(path: DocumentPath, current_obj: Any) -> str:
class ESPHomeDumper(yaml.SafeDumper):
# Default for the base class; per-call subclass in ``dump()`` overrides.
# When True, ``represent_sensitive`` wraps values in ANSI conceal codes.
_redact_sensitive: bool = False
def represent_mapping(self, tag, mapping, flow_style=None):
value = []
node = yaml.MappingNode(tag, value, flow_style=flow_style)
@@ -992,6 +1013,20 @@ class ESPHomeDumper(yaml.SafeDumper):
return self.represent_secret(value)
return self.represent_scalar(tag="tag:yaml.org,2002:str", value=str(value))
def represent_sensitive(self, value: SensitiveStr) -> yaml.ScalarNode:
# Only the redact-and-not-a-secret branch is unique to sensitive
# values; otherwise let ``represent_stringify`` handle ``!secret``
# precedence and the plain-str fallthrough. Conceal sequence is
# emitted as literal ``\033`` text (not actual ESC bytes) so the
# output matches the prior regex format and device-builder's
# ``\033[8m...\033[28m`` parser keeps working.
if self._redact_sensitive and not is_secret(value):
return self.represent_scalar(
tag="tag:yaml.org,2002:str",
value=f"\\033[8m{value}\\033[28m",
)
return self.represent_stringify(value)
# pylint: disable=arguments-renamed
def represent_bool(self, value):
return self.represent_scalar(
@@ -1063,6 +1098,8 @@ ESPHomeDumper.add_multi_representer(
)
ESPHomeDumper.add_multi_representer(bool, ESPHomeDumper.represent_bool)
ESPHomeDumper.add_multi_representer(str, ESPHomeDumper.represent_stringify)
# MRO-walked dispatch; SensitiveStr's own entry wins over the str one.
ESPHomeDumper.add_multi_representer(SensitiveStr, ESPHomeDumper.represent_sensitive)
ESPHomeDumper.add_multi_representer(int, ESPHomeDumper.represent_int)
ESPHomeDumper.add_multi_representer(float, ESPHomeDumper.represent_float)
ESPHomeDumper.add_multi_representer(_BaseAddress, ESPHomeDumper.represent_stringify)