[store_yaml] Swap secrets in wrapper representers, EsphomeError on cross-anchor paths, branched warning

This commit is contained in:
J. Nick Koston
2026-07-26 08:57:27 -10:00
parent b20e2e0448
commit 180414dab4
4 changed files with 78 additions and 6 deletions
+21 -5
View File
@@ -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)
+14 -1
View File
@@ -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):
@@ -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
+18
View File
@@ -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