mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
[core] Ship secrets referenced by remote package files in config bundles (#18053)
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user