From 180414dab4b1309648416e689d357e7e657bd7ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Jul 2026 08:57:27 -1000 Subject: [PATCH] [store_yaml] Swap secrets in wrapper representers, EsphomeError on cross-anchor paths, branched warning --- esphome/components/store_yaml/__init__.py | 26 +++++++++++++++---- esphome/yaml_util.py | 15 ++++++++++- .../unit_tests/components/test_store_yaml.py | 25 ++++++++++++++++++ tests/unit_tests/test_yaml_util.py | 18 +++++++++++++ 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/esphome/components/store_yaml/__init__.py b/esphome/components/store_yaml/__init__.py index c7f5444d7a..150b85f3ba 100644 --- a/esphome/components/store_yaml/__init__.py +++ b/esphome/components/store_yaml/__init__.py @@ -71,10 +71,18 @@ def _final_validate(config: ConfigType) -> ConfigType: if CONF_KEY in encryption: return config if config.get(CONF_ALLOW_UNENCRYPTED): - _LOGGER.warning( - "store_yaml is enabled without API encryption; any client that can " - "reach the device on the network can pull the embedded YAML." - ) + if config.get(CONF_INCLUDE_SECRETS): + _LOGGER.warning( + "store_yaml is enabled without API encryption and with " + "include_secrets; any client that can reach the device on the " + "network can pull the embedded YAML including the verbatim " + "contents of secrets.yaml." + ) + else: + _LOGGER.warning( + "store_yaml is enabled without API encryption; any client that " + "can reach the device on the network can pull the embedded YAML." + ) return config raise cv.Invalid( "store_yaml requires API encryption (configure `api.encryption.key`). " @@ -132,7 +140,15 @@ def _gather_files( # in $HOME) keep their ".." components so the include graph is preserved # and files from different directories with the same basename don't # collide. - rel_str = path.relative_to(root, walk_up=True).as_posix() + try: + rel_str = path.relative_to(root, walk_up=True).as_posix() + except ValueError as err: + # Different anchors (a Windows file on another drive) cannot be + # expressed relative to the config root. + raise EsphomeError( + f"store_yaml: cannot place {path} in the recovery envelope; " + f"it does not share a root with {root}: {err}" + ) from err if path in discovered.secrets: secret_rels.add(rel_str) diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 6abd7cfa79..85b0646b1a 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -901,7 +901,10 @@ def secret_values_registered(values: dict[str, str]) -> Generator[set[str]]: try: yield emitted finally: - _EMITTED_SECRET_NAMES.remove(emitted) + # Contexts unwind LIFO, so the innermost collector is always last; + # pop() removes by position where remove() would match the first + # *equal* set and could strip an outer context's collector. + _EMITTED_SECRET_NAMES.pop() for value in added: _SECRET_VALUES.pop(value, None) @@ -1186,17 +1189,27 @@ class ESPHomeDumper(yaml.SafeDumper): return self.represent_scalar(tag="!lambda", value=value.value, style="|") def represent_extend(self, value): + # Consult is_secret like the other scalar representers so a payload + # equal to a registered secret is never written out in cleartext. + if is_secret(value.value): + return self.represent_secret(value.value) return self.represent_scalar(tag="!extend", value=value.value) def represent_remove(self, value): + if is_secret(value.value): + return self.represent_secret(value.value) return self.represent_scalar(tag="!remove", value=value.value) def represent_include_file(self, value): if value.vars: + # The mapping values route through the regular representers, + # which already consult is_secret. mapping = {"file": value.file.as_posix(), "vars": value.vars} return self.represent_mapping( tag="!include", mapping=mapping, flow_style=False ) + if is_secret(value.file.as_posix()): + return self.represent_secret(value.file.as_posix()) return self.represent_scalar(tag="!include", value=value.file.as_posix()) def represent_id(self, value): diff --git a/tests/unit_tests/components/test_store_yaml.py b/tests/unit_tests/components/test_store_yaml.py index 46ca070609..3293497c84 100644 --- a/tests/unit_tests/components/test_store_yaml.py +++ b/tests/unit_tests/components/test_store_yaml.py @@ -615,3 +615,28 @@ def test_redacted_outside_root_secrets_gets_root_skeleton( assert "secrets.yaml" in files assert 'api_key: ""' in files["secrets.yaml"].decode() assert b"SUPER_SECRET" not in b"".join(files.values()) + + +def test_gather_raises_esphome_error_on_cross_anchor_path( + project: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A file that cannot be made relative to the config root (a Windows + cross-drive path) surfaces as EsphomeError, not a raw ValueError.""" + + def fake_relative_to(self: Path, other: Path, walk_up: bool = False) -> Path: + raise ValueError("paths have different anchors") + + monkeypatch.setattr(Path, "relative_to", fake_relative_to) + discovered = _sources(project, "entry.yaml") + with pytest.raises(EsphomeError, match="does not share a root"): + _gather_files(discovered) + + +def test_final_validate_unencrypted_with_secrets_names_secrets_yaml( + caplog: pytest.LogCaptureFixture, +) -> None: + """allow_unencrypted combined with include_secrets warns about the + verbatim secrets.yaml specifically.""" + config = {CONF_ALLOW_UNENCRYPTED: True, "include_secrets": True} + assert _run_final_validate({"api": {}}, config) is config + assert "verbatim contents of secrets.yaml" in caplog.text diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 75f36201c3..15279de920 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1557,3 +1557,21 @@ def test_merge_include_no_overlap_records_nothing(tmp_path: Path) -> None: assert result["api"] == {"reboot_timeout": "5min"} assert result["logger"] == {"level": "DEBUG"} assert yaml_util.take_dropped_merge_keys() == [] + + +def test_wrapper_representers_consult_is_secret() -> None: + """!extend / !remove payloads and scalar !include paths equal to a + registered secret are swapped, never written in cleartext.""" + from esphome.config_helpers import Extend, Remove + + with yaml_util.secret_values_registered({"hunter2": "the_secret"}): + out = yaml_util.dump( + { + "a": Extend("hunter2"), + "b": Remove("hunter2"), + "c": Extend("plain_id"), + } + ) + assert out.count("!secret 'the_secret'") == 2 + assert "hunter2" not in out + assert "!extend 'plain_id'" in out