|
5 | 5 | import logging |
6 | 6 | import os |
7 | 7 | import pathlib |
| 8 | +import re |
8 | 9 | import typing |
9 | 10 | from collections.abc import Mapping |
10 | 11 |
|
11 | 12 | import pydantic |
12 | 13 | import yaml |
13 | 14 | from packaging.requirements import Requirement |
14 | 15 | from packaging.utils import canonicalize_name |
15 | | -from pydantic import AnyUrl, Field |
| 16 | +from pydantic import AnyUrl, Field, PrivateAttr, StringConstraints |
16 | 17 | from pydantic_core import core_schema |
17 | 18 |
|
18 | 19 | # from ._resolver import SourceResolver |
@@ -72,6 +73,161 @@ class SbomSettings(pydantic.BaseModel): |
72 | 73 | """ |
73 | 74 |
|
74 | 75 |
|
| 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 | + |
75 | 231 | class PurlConfig(pydantic.BaseModel): |
76 | 232 | """Per-package purl configuration for SBOM generation. |
77 | 233 |
|
|
0 commit comments