[core] Ship secrets referenced by remote package files in config bundles (#18053)

This commit is contained in:
J. Nick Koston
2026-08-04 11:05:52 -05:00
committed by GitHub
parent c2ddc17063
commit adb86a052c
7 changed files with 171 additions and 10 deletions
+36
View File
@@ -29,6 +29,7 @@ from esphome.const import (
CONF_TYPE, CONF_TYPE,
) )
from esphome.core import CORE, EsphomeError from esphome.core import CORE, EsphomeError
from esphome.util import filter_yaml_files
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -128,6 +129,9 @@ class BundleData:
"""Files components asked to include, keyed under DOMAIN in CORE.data.""" """Files components asked to include, keyed under DOMAIN in CORE.data."""
extra_files: list[Path] = field(default_factory=list) extra_files: list[Path] = field(default_factory=list)
# Directories whose YAML files are scanned for !secret references but
# never bundled, e.g. git package checkouts the builder re-fetches.
secret_scan_dirs: set[Path] = field(default_factory=set)
# Original config dir parsed from an extracted bundle's manifest.json, # Original config dir parsed from an extracted bundle's manifest.json,
# kept in the path flavor of the machine the bundle was created on. # kept in the path flavor of the machine the bundle was created on.
# The checked flag makes the manifest lookup happen at most once per run; # The checked flag makes the manifest lookup happen at most once per run;
@@ -155,6 +159,30 @@ def add_bundle_file(path: Path) -> None:
_get_data().extra_files.append(CORE.relative_config_path(path)) _get_data().extra_files.append(CORE.relative_config_path(path))
def add_secret_scan_dir(path: Path) -> None:
"""Register a directory to scan for ``!secret`` references when bundling.
The directory's files are not added to the bundle. Components call this
for YAML the build consumes without bundling it — such as git-fetched
packages, which the builder re-fetches — so the secrets those files
reference are still shipped in the filtered secrets file.
A relative path is taken as relative to the config directory.
"""
if not path.is_absolute():
path = CORE.relative_config_path(path)
_get_data().secret_scan_dirs.add(path)
def _secret_scan_yaml_files() -> list[Path]:
"""Return the YAML files inside registered secret-scan directories."""
return filter_yaml_files(
f
for scan_dir in _get_data().secret_scan_dirs
for f in yaml_util.find_files(scan_dir, "*")
)
# Windows paths start with a drive letter or contain backslashes; POSIX # Windows paths start with a drive letter or contain backslashes; POSIX
# paths do neither in practice, so this is how the flavor of a recorded # paths do neither in practice, so this is how the flavor of a recorded
# path string is recognized on any host. # path string is recognized on any host.
@@ -310,6 +338,7 @@ class ConfigBundleCreator:
yaml_sources = [ yaml_sources = [
bf.source for bf in files if bf.source.suffix in (".yaml", ".yml") bf.source for bf in files if bf.source.suffix in (".yaml", ".yml")
] ]
yaml_sources.extend(_secret_scan_yaml_files())
used_secret_keys = _find_used_secret_keys(yaml_sources) used_secret_keys = _find_used_secret_keys(yaml_sources)
filtered_secrets = self._build_filtered_secrets(used_secret_keys) filtered_secrets = self._build_filtered_secrets(used_secret_keys)
@@ -394,6 +423,13 @@ class ConfigBundleCreator:
""" """
discovered = yaml_util.discover_user_yaml_files(self._config_path) discovered = yaml_util.discover_user_yaml_files(self._config_path)
self._secrets_paths.update(discovered.secrets) self._secrets_paths.update(discovered.secrets)
# A !secret inside a file this re-parse does not reach (for example
# a git-fetched package the builder re-fetches) still resolves
# against the config-dir secrets.yaml at build time, so always
# consider that file; filtering no-ops when no key matches.
default_secrets = self._config_dir / yaml_util.SECRET_YAML
if default_secrets.is_file():
self._secrets_paths.add(default_secrets.resolve())
config_resolved = self._config_path.resolve() config_resolved = self._config_path.resolve()
for fpath in discovered.files: for fpath in discovered.files:
if fpath == config_resolved: if fpath == config_resolved:
+8
View File
@@ -201,6 +201,14 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
if base_path := config.get(CONF_PATH): if base_path := config.get(CONF_PATH):
repo_dir = repo_dir / base_path repo_dir = repo_dir / base_path
# Deferred import: keeps esphome.bundle off the device builder's
# startup path, since packages is loaded on every config parse.
from esphome.bundle import add_secret_scan_dir
# Register the path-narrowed dir, not repo_root, so example configs
# elsewhere in the repo do not widen the shipped secrets.
add_secret_scan_dir(repo_dir)
for file in config[CONF_FILES]: for file in config[CONF_FILES]:
if isinstance(file, str): if isinstance(file, str):
files.append({CONF_PATH: file, CONF_VARS: {}}) files.append({CONF_PATH: file, CONF_VARS: {}})
+2 -2
View File
@@ -1,5 +1,5 @@
import collections import collections
from collections.abc import Callable from collections.abc import Callable, Iterable
from dataclasses import dataclass from dataclasses import dataclass
import io import io
import logging import logging
@@ -356,7 +356,7 @@ def list_yaml_files(configs: list[str | Path]) -> list[Path]:
return sorted(files) return sorted(files)
def filter_yaml_files(files: list[Path]) -> list[Path]: def filter_yaml_files(files: Iterable[Path]) -> list[Path]:
return [ return [
f f
for f in files for f in files
+7 -7
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable, Generator from collections.abc import Callable, Generator, Iterator
from contextlib import contextmanager, suppress from contextlib import contextmanager, suppress
from dataclasses import dataclass, field from dataclasses import dataclass, field
import functools import functools
@@ -791,12 +791,12 @@ class ESPHomeLoaderMixin:
@_add_data_ref @_add_data_ref
def construct_include_dir_list(self, node: yaml.Node) -> list[dict[str, Any]]: def construct_include_dir_list(self, node: yaml.Node) -> list[dict[str, Any]]:
files = filter_yaml_files(_find_files(self._rel_path(node.value), "*.yaml")) files = filter_yaml_files(find_files(self._rel_path(node.value), "*.yaml"))
return [self.yaml_loader(f) for f in files] return [self.yaml_loader(f) for f in files]
@_add_data_ref @_add_data_ref
def construct_include_dir_merge_list(self, node: yaml.Node) -> list[dict[str, Any]]: def construct_include_dir_merge_list(self, node: yaml.Node) -> list[dict[str, Any]]:
files = filter_yaml_files(_find_files(self._rel_path(node.value), "*.yaml")) files = filter_yaml_files(find_files(self._rel_path(node.value), "*.yaml"))
merged_list = [] merged_list = []
for fname in files: for fname in files:
loaded_yaml = self.yaml_loader(fname) loaded_yaml = self.yaml_loader(fname)
@@ -808,7 +808,7 @@ class ESPHomeLoaderMixin:
def construct_include_dir_named( def construct_include_dir_named(
self, node: yaml.Node self, node: yaml.Node
) -> OrderedDict[str, dict[str, Any]]: ) -> OrderedDict[str, dict[str, Any]]:
files = filter_yaml_files(_find_files(self._rel_path(node.value), "*.yaml")) files = filter_yaml_files(find_files(self._rel_path(node.value), "*.yaml"))
mapping = OrderedDict() mapping = OrderedDict()
for fname in files: for fname in files:
filename = fname.stem filename = fname.stem
@@ -819,7 +819,7 @@ class ESPHomeLoaderMixin:
def construct_include_dir_merge_named( def construct_include_dir_merge_named(
self, node: yaml.Node self, node: yaml.Node
) -> OrderedDict[str, dict[str, Any]]: ) -> OrderedDict[str, dict[str, Any]]:
files = filter_yaml_files(_find_files(self._rel_path(node.value), "*.yaml")) files = filter_yaml_files(find_files(self._rel_path(node.value), "*.yaml"))
mapping = OrderedDict() mapping = OrderedDict()
for fname in files: for fname in files:
loaded_yaml = self.yaml_loader(fname) loaded_yaml = self.yaml_loader(fname)
@@ -1015,8 +1015,8 @@ def _is_file_valid(name: str) -> bool:
return not name.startswith(".") return not name.startswith(".")
def _find_files(directory: Path, pattern): def find_files(directory: Path, pattern: str) -> Iterator[Path]:
"""Recursively load files in a directory.""" """Recursively find files in a directory matching *pattern*, skipping hidden entries."""
for root, dirs, files in os.walk(directory): for root, dirs, files in os.walk(directory):
dirs[:] = [d for d in dirs if _is_file_valid(d)] dirs[:] = [d for d in dirs if _is_file_valid(d)]
for f in files: for f in files:
@@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from esphome import bundle
from esphome.components.packages import ( from esphome.components.packages import (
CONFIG_SCHEMA, CONFIG_SCHEMA,
_substitute_package_definition, _substitute_package_definition,
@@ -1694,3 +1695,34 @@ def test_resolve_packages_does_not_apply_extend_remove() -> None:
# over the package value during merge), and the marker is not # over the package value during merge), and the marker is not
# resolved by this wrapper. # resolved by this wrapper.
assert isinstance(result[CONF_WIFI], Remove) assert isinstance(result[CONF_WIFI], Remove)
@patch("esphome.git.clone_or_update")
def test_remote_package_registers_checkout_for_secret_scan(
mock_clone_or_update, tmp_path: Path
) -> None:
"""Loading a remote package registers its path-narrowed checkout dir
as a bundle secret-scan dir (issue 18023)."""
repo_root = tmp_path / "repo"
package_dir = repo_root / "packages"
package_dir.mkdir(parents=True)
(package_dir / "base.yml").write_text(
f"sensor:\n - platform: {TEST_SENSOR_PLATFORM_1}\n name: {TEST_SENSOR_NAME_1}\n"
)
mock_clone_or_update.return_value = (repo_root, None)
config = {
CONF_PACKAGES: {
"package1": {
CONF_URL: "https://github.com/esphome/non-existant-repo",
CONF_REF: "main",
CONF_PATH: "packages",
CONF_FILES: ["base.yml"],
CONF_REFRESH: "1d",
}
}
}
packages_pass(config)
assert package_dir in bundle._get_data().secret_scan_dirs
assert repo_root not in bundle._get_data().secret_scan_dirs
+39
View File
@@ -23,6 +23,7 @@ from esphome.bundle import (
_default_target_dir, _default_target_dir,
_find_used_secret_keys, _find_used_secret_keys,
add_bundle_file, add_bundle_file,
add_secret_scan_dir,
extract_bundle, extract_bundle,
is_bundle_path, is_bundle_path,
prepare_bundle_for_compile, prepare_bundle_for_compile,
@@ -1651,6 +1652,44 @@ def test_create_bundle_filters_secrets_quoted(tmp_path: Path) -> None:
assert "unused" not in secrets_data assert "unused" not in secrets_data
def test_create_bundle_scans_remote_package_files_for_secrets(tmp_path: Path) -> None:
"""Secrets referenced only by git-fetched package files must be shipped
in the filtered secrets.yaml (regression test for issue 18023)."""
config_dir = _setup_config_dir(tmp_path)
secrets = config_dir / "secrets.yaml"
secrets.write_text("ota_password: hunter2\nunused: should_not_appear\n")
# Simulate a git-fetched package checkout referencing a secret
repo_dir = config_dir / ".esphome" / "packages" / "6bcd6aa8"
package_dir = repo_dir / "packages"
package_dir.mkdir(parents=True)
(package_dir / "base.yml").write_text(
"ota:\n - platform: esphome\n password: !secret ota_password\n"
)
# References inside hidden directories such as .git must not be scanned
hidden_dir = repo_dir / ".git"
hidden_dir.mkdir()
(hidden_dir / "leak.yaml").write_text("password: !secret unused\n")
add_secret_scan_dir(repo_dir)
creator = ConfigBundleCreator({})
result = creator.create_bundle()
assert result.manifest[ManifestKey.HAS_SECRETS] is True
buf = io.BytesIO(result.data)
with tarfile.open(fileobj=buf, mode="r:gz") as tar:
secrets_data = tar.extractfile("secrets.yaml").read().decode()
names = tar.getnames()
assert "ota_password" in secrets_data
assert "hunter2" in secrets_data
assert "unused" not in secrets_data
# The package checkout itself must not be bundled
assert not any("base.yml" in name for name in names)
def test_create_bundle_no_secrets(tmp_path: Path) -> None: def test_create_bundle_no_secrets(tmp_path: Path) -> None:
_setup_config_dir(tmp_path) _setup_config_dir(tmp_path)
+47 -1
View File
@@ -282,8 +282,54 @@ test: !include_dir_named test_dir
assert ".hidden_dir" not in actual["test"] assert ".hidden_dir" not in actual["test"]
def test_include_dir_list(tmp_path: Path) -> None:
"""!include_dir_list loads every .yaml file in the directory as a list."""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
(test_dir / "a.yaml").write_text("key: value_a")
(test_dir / "b.yaml").write_text("key: value_b")
test_yaml = tmp_path / "test.yaml"
test_yaml.write_text("test: !include_dir_list test_dir\n")
actual = yaml_util.load_yaml(test_yaml)
assert len(actual["test"]) == 2
assert {entry["key"] for entry in actual["test"]} == {"value_a", "value_b"}
def test_include_dir_merge_list(tmp_path: Path) -> None:
"""!include_dir_merge_list concatenates the lists from every .yaml file."""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
(test_dir / "a.yaml").write_text("- item_a1\n- item_a2\n")
(test_dir / "b.yaml").write_text("- item_b1\n")
test_yaml = tmp_path / "test.yaml"
test_yaml.write_text("test: !include_dir_merge_list test_dir\n")
actual = yaml_util.load_yaml(test_yaml)
assert sorted(actual["test"]) == ["item_a1", "item_a2", "item_b1"]
def test_include_dir_merge_named(tmp_path: Path) -> None:
"""!include_dir_merge_named merges the mappings from every .yaml file."""
test_dir = tmp_path / "test_dir"
test_dir.mkdir()
(test_dir / "a.yaml").write_text("key_a: value_a")
(test_dir / "b.yaml").write_text("key_b: value_b")
test_yaml = tmp_path / "test.yaml"
test_yaml.write_text("test: !include_dir_merge_named test_dir\n")
actual = yaml_util.load_yaml(test_yaml)
assert actual["test"] == {"key_a": "value_a", "key_b": "value_b"}
def test_find_files_recursive(fixture_path: Path, tmp_path: Path) -> None: def test_find_files_recursive(fixture_path: Path, tmp_path: Path) -> None:
"""Test that _find_files works recursively through include_dir_named.""" """Test that find_files works recursively through include_dir_named."""
# Copy fixture directory to temporary location # Copy fixture directory to temporary location
src_dir = fixture_path / "yaml_util" src_dir = fixture_path / "yaml_util"
dst_dir = tmp_path / "yaml_util" dst_dir = tmp_path / "yaml_util"