[store_yaml] Scan lambda and include payloads, warn on secret overlaps, explicit key gate

This commit is contained in:
J. Nick Koston
2026-07-26 08:14:44 -10:00
parent dbfdd591e8
commit b20e2e0448
3 changed files with 127 additions and 21 deletions
+4 -2
View File
@@ -1936,8 +1936,10 @@ bool APIConnection::send_device_info_response_() {
resp.has_deep_sleep = deep_sleep::global_has_deep_sleep;
#endif
#ifdef USE_STORE_YAML
// Codegen always embeds a non-empty blob, so presence of the component implies data.
resp.has_store_yaml = store_yaml::global_store_yaml != nullptr;
// Compile-time knowledge: codegen always embeds a non-empty blob when the
// component is compiled in. Deriving this from the runtime pointer could
// report false to a client connecting before store_yaml's setup() ran.
resp.has_store_yaml = true;
#endif
#ifdef ESPHOME_PROJECT_NAME
#ifdef USE_ESP8266
+54 -19
View File
@@ -10,9 +10,10 @@ from esphome import yaml_util
import esphome.codegen as cg
from esphome.components import packages
from esphome.components.api import CONF_ENCRYPTION
from esphome.config_helpers import Extend, Remove
import esphome.config_validation as cv
from esphome.const import CONF_API, CONF_ID, CONF_RAW_DATA_ID
from esphome.core import CORE, EsphomeError, HexInt
from esphome.const import CONF_API, CONF_ID, CONF_KEY, CONF_RAW_DATA_ID
from esphome.core import CORE, EsphomeError, HexInt, Lambda
import esphome.final_validate as fv
from esphome.helpers import ensure_unique_string
from esphome.types import ConfigType
@@ -63,7 +64,11 @@ def _final_validate(config: ConfigType) -> ConfigType:
isolated lab setups where the user has accepted the trade-off."""
full = fv.full_config.get()
api_conf = full.get(CONF_API, {})
if api_conf.get(CONF_ENCRYPTION):
# Explicitly require a configured key: a keyless provisionable
# `api: encryption:` accepts the well-known all-zeros PSK until
# provisioned, so anyone could provision it and pull the YAML.
encryption = api_conf.get(CONF_ENCRYPTION) or {}
if CONF_KEY in encryption:
return config
if config.get(CONF_ALLOW_UNENCRYPTED):
_LOGGER.warning(
@@ -155,7 +160,12 @@ def _iter_nodes(
node: object, path: tuple[str, ...] = ()
) -> Generator[tuple[tuple[str, ...], object, bool]]:
"""Yield (config_path, value, is_key) for every mapping key and scalar in
a config tree. Keys are yielded at the path of their mapping."""
a config tree. Keys are yielded at the path of their mapping.
Wrapper types the dumper renders as text are unwrapped so their payloads
are scanned too: `!lambda` bodies, `!extend`/`!remove` ids, and `!include`
file paths plus `vars:` values.
"""
if isinstance(node, dict):
for key, value in node.items():
yield path, str(key), True
@@ -163,6 +173,12 @@ def _iter_nodes(
elif isinstance(node, (list, tuple)):
for item in node:
yield from _iter_nodes(item, path)
elif isinstance(node, (Lambda, Extend, Remove)):
yield path, node.value, False
elif isinstance(node, yaml_util.IncludeFile):
yield path, str(node.file), False
if node.vars:
yield from _iter_nodes(node.vars, path)
elif isinstance(node, (str, int, float)) and not isinstance(node, bool):
yield path, node, False
@@ -185,12 +201,13 @@ def _check_sensitive_usage(
- As a mapping key: fail the build. The dumper's value-keyed swap would
rewrite the key and corrupt the recovered structure.
- Strictly inside a larger scalar (a lambda body, a URL like
http://user:pw@host): fail the build. The whole-scalar swap cannot
redact it, so it would ship verbatim. Scanning tree scalars (not
serialized text) means key names, tags, and generated `!secret`
references can never false-positive; a short value inside an unrelated
longer scalar (an SSID of "esp32" inside "esp32dev") still can, but
shipping a promised-redacted secret is the worse failure, so fail closed.
http://user:pw@host): the whole-scalar swap cannot redact it, so it
would ship verbatim. Inline sensitive values fail the build (the
move-into-!secret remedy applies); values already sourced from a real
`!secret` only warn, since overlaps like an SSID inside an entity name
are common and the value stays in the user's secrets.yaml either way.
Scanning tree scalars (not serialized text) means key names, tags, and
generated `!secret` references can never false-positive.
- As a whole scalar at an unrelated location: warn only. The swap rewrites
it to the `!secret` reference, which stays semantically identical until
the user fills in a different value during recovery. Occurrences under
@@ -227,11 +244,27 @@ def _check_sensitive_usage(
info.secret_name,
)
continue
embedded.extend(
f"{other.config_path} (inside {'.'.join(path)} in {rel})"
for value, other in sensitive.items()
if value in text
)
for value, other in sensitive.items():
if value not in text:
continue
if yaml_util.is_secret(value) is not None:
# The value lives in a real secrets.yaml, so the
# move-into-!secret remedy does not apply; overlaps like
# an SSID inside an entity name are common and benign.
# Warn naming the location, never the value.
_LOGGER.warning(
"store_yaml: the value of !secret %s (sensitive at %s) "
"appears inside the value at %s in %s; substring "
"occurrences are not redacted",
other.secret_name,
other.config_path,
".".join(path),
rel,
)
else:
embedded.append(
f"{other.config_path} (inside {'.'.join(path)} in {rel})"
)
if key_hits:
raise EsphomeError(
"store_yaml: sensitive value(s) are also used as mapping keys: "
@@ -373,10 +406,12 @@ def _generate_redacted_files(
(rel, skeleton if rel in secret_rels else texts[rel].encode("utf-8"))
for rel, _ in entries
]
if skeleton_keys and not secret_rels:
# The generated files reference `!secret` keys but the project has no
# secrets file (all secrets were inline) — ship a synthetic one so the
# recovered config is complete.
if skeleton_keys and yaml_util.SECRET_YAML not in secret_rels:
# The generated files reference `!secret` keys but no captured secrets
# file lands at the config root (none exists, or it resolves outside
# the root, e.g. a symlink target). `!secret` resolution looks for
# secrets.yaml beside the config, so ship a synthetic root skeleton to
# keep the recovered config loadable.
result.append((yaml_util.SECRET_YAML, skeleton))
return result
@@ -546,3 +546,72 @@ def test_final_validate_allows_unencrypted_with_escape_hatch(
config = {CONF_ALLOW_UNENCRYPTED: True}
assert _run_final_validate({"api": {}}, config) is config
assert "without API encryption" in caplog.text
def test_redacted_lambda_body_leak_fails_build(project: Path) -> None:
"""A sensitive value inside a !lambda body would be emitted verbatim by
the dumper; the scan must catch it."""
(project / "wifi.yaml").write_text(
"password: my_password\nx: !lambda 'return \"my_password\";'\n"
)
CORE.config = {"wifi": [{"password": SensitiveStr("my_password")}]}
discovered = _sources(project, "wifi.yaml")
with pytest.raises(EsphomeError, match="embedded"):
_gather_redacted(discovered)
def test_redacted_include_vars_leak_fails_build(project: Path) -> None:
"""A sensitive value inside an !include vars: mapping is emitted by the
dumper; the scan must catch it."""
(project / "entry.yaml").write_text(
"password: my_password\n"
"pkg: !include {file: wifi.yaml, vars: {url: 'http://user:my_password@host'}}\n"
)
CORE.config = {"wifi": [{"password": SensitiveStr("my_password")}]}
discovered = _sources(project, "entry.yaml", "wifi.yaml")
with pytest.raises(EsphomeError, match="embedded"):
_gather_redacted(discovered)
def test_redacted_secret_sourced_overlap_warns_not_fails(
project: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A value from a real !secret appearing inside another scalar (SSID in an
entity name) warns instead of failing; the remedy text of the hard error
does not apply to it."""
yaml_util._SECRET_VALUES["Kitchen"] = "wifi_ssid"
(project / "wifi.yaml").write_text("ssid: Kitchen\nname: Kitchen Temperature\n")
CORE.config = {"wifi": [{"ssid": SensitiveStr("Kitchen")}]}
discovered = _sources(project, "wifi.yaml")
files = _gather_redacted(discovered)
assert b"!secret 'wifi_ssid'" in files["wifi.yaml"]
assert "appears inside the value at name in wifi.yaml" in caplog.text
def test_final_validate_rejects_keyless_encryption() -> None:
"""A keyless provisionable `api: encryption:` accepts the all-zeros PSK
until provisioned, so it must not satisfy the gate."""
with pytest.raises(cv.Invalid, match="requires API encryption"):
_run_final_validate(
{"api": {"encryption": {}}}, {CONF_ALLOW_UNENCRYPTED: False}
)
def test_redacted_outside_root_secrets_gets_root_skeleton(
project: Path, tmp_path: Path
) -> None:
"""A secrets file resolving outside the config root still yields a root
secrets.yaml skeleton, so the recovered config can resolve !secret."""
target = tmp_path / "actual_creds.yaml"
target.write_text("api_key: SUPER_SECRET\n")
link = project / "secrets.yaml"
link.unlink()
link.symlink_to(target)
resolved = link.resolve()
CORE.config_path = project / "entry.yaml"
files = _gather_redacted(
DiscoveredYamlFiles([project / "entry.yaml", resolved], {resolved})
)
assert "secrets.yaml" in files
assert 'api_key: ""' in files["secrets.yaml"].decode()
assert b"SUPER_SECRET" not in b"".join(files.values())