mirror of
https://github.com/esphome/esphome.git
synced 2026-08-31 01:56:01 +00:00
[store_yaml] Swap secrets in wrapper representers, EsphomeError on cross-anchor paths, branched warning
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user