Skip to content

Commit 680d644

Browse files
tiranclaude
andcommitted
feat(packagesettings): add ExternalCommands model for env filtering
Add `ExternalCommands` Pydantic model with `keep_env` / `delete_env` pattern lists, a `filter_env()` method, and a `DEFAULT_KEEP_ENV` class variable for essential variables (HOME, PATH, LC_*, TERM, TZ, TMPDIR, etc.). `delete_env` matching is case-insensitive so credentials cannot slip through due to unexpected capitalisation. Non-POSIX env var keys are always stripped by `filter_env()`. Not yet wired into `external_commands.run()`. See: #1083 Co-Authored-By: Claude <claude@anthropic.com> Signed-off-by: Christian Heimes <cheimes@redhat.com>
1 parent 4000c2d commit 680d644

5 files changed

Lines changed: 419 additions & 12 deletions

File tree

docs/proposals/filter-env.md

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,20 +68,30 @@ validation error.
6868

6969
### Evaluation order
7070

71-
`keep_env` is evaluated before `delete_env`. All checks are case-insensitive
72-
and short-circuit. If a variable matches the hard-coded always-keep set or
73-
an entry in `keep_env`, then the variable is kept.
71+
`keep_env` is evaluated before `delete_env`. If a variable matches the
72+
hard-coded always-keep set or an entry in `keep_env`, then the variable
73+
is kept.
74+
75+
`delete_env` matching is **case-insensitive** for maximum convenience
76+
and security -- a pattern `aws_*` removes `AWS_SECRET_ACCESS_KEY`
77+
regardless of casing, so credentials cannot slip through due to
78+
unexpected capitalisation. `keep_env` and the always-keep set are
79+
case-sensitive, matching the exact variable names used in practice.
7480

7581
For each variable in `os.environ`:
7682

77-
1. If it is in a hard-coded always-keep set -- **keep**, regardless of
83+
1. If the key is not a valid POSIX name (`[A-Za-z_][A-Za-z0-9_]*`) --
84+
**delete**. This removes keys with dashes, dots, embedded spaces,
85+
or bash-exported function definitions (`BASH_FUNC_*%%`) that no
86+
build script should need.
87+
2. If it is in a hard-coded always-keep set -- **keep**, regardless of
7888
configuration. The always-keep set contains variables required for
79-
basic subprocess operation and proxy settings: `HOME`, `HOSTNAME`,
80-
`LANG`, `LANGUAGE`, `LC_*`, `LOGNAME`, `NO_COLOR`, `PATH`, `SHELL`,
81-
`USER`, `http_proxy`, `https_proxy`, `no_proxy`.
82-
2. If any `keep_env` entry matches -- **keep**.
83-
3. If any `delete_env` entry matches -- **delete**.
84-
4. Otherwise -- **keep** (default passthrough).
89+
basic subprocess operation: `HOME`, `HOSTNAME`, `LANG`, `LANGUAGE`,
90+
`LC_*`, `LOGNAME`, `NO_COLOR`, `PATH`, `SHELL`, `TEMP`, `TERM`,
91+
`TMP`, `TMPDIR`, `TZ`, `USER`.
92+
3. If any `keep_env` entry matches (case-sensitive) -- **keep**.
93+
4. If any `delete_env` entry matches (case-insensitive) -- **delete**.
94+
5. Otherwise -- **keep** (default passthrough).
8595

8696
`delete_env: ['*']` can be used to prevent passthrough. It filters all
8797
variables that neither match the always-keep set nor `keep_env` entries.

src/fromager/packagesettings/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from ._models import (
55
BuildOptions,
66
DownloadSource,
7+
ExternalCommands,
78
GitOptions,
89
PackageSettings,
910
ProjectOverride,
@@ -57,6 +58,7 @@
5758
"DownloadSource",
5859
"EnvKey",
5960
"EnvVars",
61+
"ExternalCommands",
6062
"GitHubTagCloneResolver",
6163
"GitHubTagDownloadResolver",
6264
"GitLabTagCloneResolver",

src/fromager/packagesettings/_models.py

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55
import logging
66
import os
77
import pathlib
8+
import re
89
import typing
910
from collections.abc import Mapping
1011

1112
import pydantic
1213
import yaml
1314
from packaging.requirements import Requirement
1415
from packaging.utils import canonicalize_name
15-
from pydantic import AnyUrl, Field
16+
from pydantic import AnyUrl, Field, PrivateAttr, StringConstraints
1617
from pydantic_core import core_schema
1718

1819
# from ._resolver import SourceResolver
@@ -72,6 +73,161 @@ class SbomSettings(pydantic.BaseModel):
7273
"""
7374

7475

76+
# Environment variable filter patterns for ExternalCommands.
77+
# Pattern: starts with letter or underscore, rest is letters/digits/underscores,
78+
# optionally ending with ``*`` (trailing wildcard).
79+
# DeleteEnvPattern additionally allows bare ``*`` (catch-all).
80+
KeepEnvPattern = typing.Annotated[
81+
str,
82+
StringConstraints(pattern=r"^[a-zA-Z_][a-zA-Z0-9_]*\*?$"),
83+
]
84+
85+
DeleteEnvPattern = typing.Annotated[
86+
str,
87+
StringConstraints(pattern=r"^(\*|[a-zA-Z_][a-zA-Z0-9_]*\*?)$"),
88+
]
89+
90+
91+
# POSIX.1-2024 sec. 8.1: environment variable names consist of uppercase
92+
# letters, digits, and underscores and do not begin with a digit. We
93+
# also accept lowercase letters for portability (common on Linux).
94+
_POSIX_ENV_KEY_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
95+
96+
97+
def _compile_env_patterns(
98+
patterns: tuple[str, ...],
99+
*,
100+
case_insensitive: bool = False,
101+
) -> re.Pattern[str]:
102+
"""Compile env filter patterns into a single regex for ``fullmatch``.
103+
104+
Exact patterns (e.g. ``HOME``) become ``HOME`` and prefix patterns
105+
(e.g. ``LC_*``) become ``LC_.*``. The result is
106+
``HOME|LC_.*|...`` (used with ``fullmatch``).
107+
108+
*patterns* must be non-empty.
109+
"""
110+
parts: list[str] = []
111+
for p in patterns:
112+
if p.endswith("*"):
113+
parts.append(re.escape(p[:-1]) + ".*")
114+
else:
115+
parts.append(re.escape(p))
116+
flags = re.IGNORECASE if case_insensitive else 0
117+
return re.compile("|".join(parts), flags)
118+
119+
120+
class ExternalCommands(pydantic.BaseModel):
121+
"""Environment variable filtering for subprocesses.
122+
123+
Variables whose keys are not valid POSIX names are always removed.
124+
A hard-coded set of variables required for basic subprocess
125+
operation (see ``DEFAULT_KEEP_ENV``) is always kept. User-supplied
126+
``keep_env`` patterns are evaluated before ``delete_env`` patterns.
127+
128+
::
129+
130+
external_commands:
131+
keep_env:
132+
- "CARGO_*"
133+
delete_env:
134+
- "CI_TOKEN"
135+
- "AWS_*"
136+
137+
.. versionadded:: 0.92.0
138+
"""
139+
140+
model_config = MODEL_CONFIG
141+
142+
DEFAULT_KEEP_ENV: typing.ClassVar[tuple[str, ...]] = (
143+
"HOME",
144+
"HOSTNAME",
145+
"LANG",
146+
"LANGUAGE",
147+
"LC_*",
148+
"LOGNAME",
149+
"NO_COLOR",
150+
"PATH",
151+
"SHELL",
152+
"TEMP",
153+
"TERM",
154+
"TMP",
155+
"TMPDIR",
156+
"TZ",
157+
"USER",
158+
)
159+
"""Patterns always kept regardless of user configuration."""
160+
161+
keep_env: list[KeepEnvPattern] = Field(default_factory=list)
162+
"""Allowlist patterns (evaluated before ``delete_env``)"""
163+
164+
delete_env: list[DeleteEnvPattern] = Field(default_factory=list)
165+
"""Blocklist patterns (evaluated after ``keep_env``)"""
166+
167+
_keep_re: re.Pattern[str] | None = PrivateAttr(default=None)
168+
_delete_re: re.Pattern[str] | None = PrivateAttr(default=None)
169+
170+
@pydantic.model_validator(mode="after")
171+
def validate_delete_env(self) -> typing.Self:
172+
"""Validate ``delete_env`` for conflicts and redundancy."""
173+
if not self.delete_env:
174+
return self
175+
if "*" in self.delete_env and len(self.delete_env) > 1:
176+
raise ValueError(
177+
"delete_env: bare '*' must be the only entry, "
178+
"additional patterns are redundant"
179+
)
180+
# Exact string overlap check. This catches obvious
181+
# configuration mistakes (e.g. ``delete_env: [HOME]``) but does
182+
# not detect all conflicts — for example ``delete_env: [LC_ALL]``
183+
# is not flagged even though ``LC_ALL`` matches the default keep
184+
# pattern ``LC_*``.
185+
keep = set(self.DEFAULT_KEEP_ENV) | set(self.keep_env)
186+
overlap = keep & set(self.delete_env)
187+
if overlap:
188+
raise ValueError(
189+
f"delete_env overlaps with keep_env / DEFAULT_KEEP_ENV: "
190+
f"{sorted(overlap)}"
191+
)
192+
return self
193+
194+
def model_post_init(self, __context: typing.Any) -> None:
195+
"""Pydantic post init hook to initialize internal data structures"""
196+
if self.delete_env:
197+
self._keep_re = _compile_env_patterns(
198+
self.DEFAULT_KEEP_ENV + tuple(self.keep_env)
199+
)
200+
if "*" not in self.delete_env:
201+
self._delete_re = _compile_env_patterns(
202+
tuple(self.delete_env), case_insensitive=True
203+
)
204+
205+
def filter_env(self, env: Mapping[str, str]) -> Mapping[str, str]:
206+
"""Filter environment variables by keep/delete patterns.
207+
208+
Variables whose keys are not valid POSIX names are always
209+
removed first. Of the remaining variables, those matching
210+
``DEFAULT_KEEP_ENV`` or ``keep_env`` are always kept. Of the
211+
rest, those matching ``delete_env`` are removed. Variables
212+
matching neither list are kept.
213+
"""
214+
# Remove keys that are not valid POSIX names, e.g. keys with
215+
# dashes, dots, spaces, or bash function exports (BASH_FUNC_*%%).
216+
env = {k: v for k, v in env.items() if _POSIX_ENV_KEY_RE.fullmatch(k)}
217+
# _keep_re is only set in model_post_init when delete_env is
218+
# non-empty, so None means no filtering is configured.
219+
if self._keep_re is None:
220+
return env
221+
if self._delete_re is None:
222+
# delete_env is ["*"]: keep only what matches
223+
return {k: v for k, v in env.items() if self._keep_re.fullmatch(k)}
224+
return {
225+
k: v
226+
for k, v in env.items()
227+
if self._keep_re.fullmatch(k) or not self._delete_re.fullmatch(k)
228+
}
229+
230+
75231
class PurlConfig(pydantic.BaseModel):
76232
"""Per-package purl configuration for SBOM generation.
77233

src/fromager/packagesettings/_settings.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from pydantic import Field
1414

1515
from .. import overrides
16-
from ._models import PackageSettings, SbomSettings
16+
from ._models import ExternalCommands, PackageSettings, SbomSettings
1717
from ._pbi import PackageBuildInfo
1818
from ._typedefs import MODEL_CONFIG, GlobalChangelog, Package, Variant
1919

@@ -45,6 +45,16 @@ class SettingsFile(pydantic.BaseModel):
4545
are generated.
4646
"""
4747

48+
external_commands: ExternalCommands = Field(default_factory=ExternalCommands)
49+
"""Environment variable filtering for subprocesses
50+
51+
Controls which environment variables are passed to child processes
52+
using ``keep_env`` / ``delete_env`` patterns. Defaults to no
53+
filtering.
54+
55+
.. versionadded:: 0.92.0
56+
"""
57+
4858
@classmethod
4959
def from_string(
5060
cls,
@@ -175,6 +185,14 @@ def sbom_settings(self) -> SbomSettings | None:
175185
"""Get global SBOM settings, or None if SBOM generation is disabled."""
176186
return self._settings.sbom
177187

188+
@property
189+
def external_commands(self) -> ExternalCommands:
190+
"""Get external commands settings.
191+
192+
.. versionadded:: 0.92.0
193+
"""
194+
return self._settings.external_commands
195+
178196
def variant_changelog(self) -> list[str]:
179197
"""Get global changelog for current variant"""
180198
return list(self._settings.changelog.get(self.variant, []))

0 commit comments

Comments
 (0)