Skip to content

Commit e634365

Browse files
authored
Makes NO_PROXY, HTTP_PROXY, HTTPS_PROXY, PYMANAGER_USERNAME and _PASSWORD values configurable in settings (#430)
Ignore values of NO_PROXY that aren't a simple 'true'. Sanitise configuration output for variables known to potentially contain sensitive info. Fixes #425
1 parent c692c46 commit e634365

9 files changed

Lines changed: 426 additions & 130 deletions

File tree

‎src/manage/commands.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,20 @@ def execute(self):
256256
# Typically configured to '%VIRTUAL_ENV%' to pick up the active environment
257257
"virtual_env": (str, None, "env", "path"),
258258

259+
# Overrides for the OS proxy configuration, typically set to '%HTTP_PROXY%'
260+
# by default to pick up environment settings, but can be overridden to empty
261+
# to disable use of the process environment.
262+
"http_proxy": (str, None, "env", "sensitive"),
263+
"https_proxy": (str, None, "env", "sensitive"),
264+
"no_proxy": (str, None, "env", "sensitive"),
265+
266+
# Global auth for accessing sources, typically set to '%PYMANAGER_USERNAME%'
267+
# and '%PYMANAGER_PASSWORD%'. These should really have been
268+
# '%PYTHON_MANAGER_USERNAME%' rather than '%PYMANAGER' - the latter is meant
269+
# for debugging/hidden options, not user-visible ones.
270+
"username": (str, None, "env", "sensitive"),
271+
"password": (str, None, "env", "sensitive"),
272+
259273
"list": {
260274
"format": (str, None, "env"),
261275
"unmanaged": (config_bool, None, "env"),
@@ -361,6 +375,13 @@ class BaseCommand:
361375
shebang_templates = {}
362376
welcome_on_update = False
363377

378+
# These variables are mainly for capturing optional environment variables.
379+
http_proxy = None
380+
https_proxy = None
381+
no_proxy = None
382+
username = None
383+
password = None
384+
364385
log_file = None
365386
_create_log_file = True
366387
keep_log = True

‎src/manage/config.py‎

Lines changed: 76 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -243,16 +243,29 @@ def get(self, key, default=None):
243243

244244

245245

246-
def _expand_vars(v, env):
246+
def _expand_vars(k, v, env):
247247
import re
248248
def _sub(m):
249+
# Double %% turns into literal %
250+
if not m.group(1):
251+
return "%"
249252
v2 = env.get(m.group(1))
250253
if v2:
251254
return v2 + (m.group(2) or "")
255+
# Extra warning for undefined environment variables that are not shaped
256+
# like identifiers (typical, but not strictly required).
257+
if not m.group(1).isidentifier():
258+
LOGGER.warn("The environment variable %%%s%% in configuration "
259+
"setting %s looks unusual. Ensure you have defined "
260+
"your configuration file correctly. This value may "
261+
"require literal percents to be escaped as %%%%.",
262+
m.group(1), k)
252263
if m.group(2):
253264
# Empty values before a path separator are not allowed
254265
raise LookupError(m.group(1))
255266
return ""
267+
# Ignore URL encoded characters. If you really want your environment
268+
# variable to start with two digits, you'll just have to change it.
256269
v2 = re.sub(r"%(.*?)%([\\/])?", _sub, v)
257270
return v2
258271

@@ -275,16 +288,16 @@ def resolve_config(cfg, source, relative_to, key_so_far="", schema=None, error_u
275288

276289
if isinstance(subschema, dict):
277290
if not isinstance(v, dict):
278-
raise InvalidConfigurationError(source, key_so_far + k, v)
291+
raise InvalidConfigurationError(source, key_so_far + k)
279292
resolve_config(v, source, relative_to, f"{key_so_far}{k}.", subschema, environ=environ)
280293
continue
281294

282295
kind, merge, *opts = subschema
283296
from_env = False
284-
if "env" in opts and isinstance(v, str):
297+
if "env" in opts and v and isinstance(v, str):
285298
try:
286299
orig_v = v
287-
v = _expand_vars(v, environ)
300+
v = _expand_vars(key_so_far + k, v, environ)
288301
from_env = orig_v != v
289302
except TypeError:
290303
pass
@@ -296,7 +309,7 @@ def resolve_config(cfg, source, relative_to, key_so_far="", schema=None, error_u
296309
try:
297310
v = kind(v)
298311
except (TypeError, ValueError):
299-
raise InvalidConfigurationError(source, key_so_far + k, v)
312+
raise InvalidConfigurationError(source, key_so_far + k, v, opts)
300313
if v and "path" in opts and not _is_valid_url(v):
301314
# Paths from the config file are relative to the config file.
302315
# Paths from the environment are relative to the current working dir
@@ -312,44 +325,83 @@ def resolve_config(cfg, source, relative_to, key_so_far="", schema=None, error_u
312325
else:
313326
v = str(v)
314327
if not _is_valid_url(v):
315-
raise InvalidConfigurationError(source, key_so_far + k, v)
328+
raise InvalidConfigurationError(source, key_so_far + k, v, opts)
316329
cfg[k] = v
317330

318331

319-
def merge_config(into_cfg, from_cfg, schema, *, source="<unknown>", overwrite=False):
332+
def merge_config(into_cfg, from_cfg, schema, *, key_so_far="", source="<unknown>", overwrite=False):
333+
if key_so_far and not key_so_far.endswith("."):
334+
key_so_far = f"{key_so_far}."
335+
320336
for k, v in from_cfg.items():
321337
if k.startswith("#"):
322338
continue
323-
try:
324-
into = into_cfg[k]
325-
except LookupError:
326-
LOGGER.debug("Setting config %s to %r", k, v)
327-
into_cfg[k] = v
328-
continue
329339

330340
try:
331341
subschema = schema[k]
332342
except LookupError:
333343
# No schema information, so let's just replace
334-
LOGGER.warn("Unknown configuration key %s in %s", k, source)
344+
LOGGER.warn("Unknown configuration key %s in %s", key_so_far + k, source)
335345
into_cfg[k] = v
336346
continue
337347

338348
if isinstance(subschema, dict):
349+
into = into_cfg.setdefault(k, {})
339350
if isinstance(into, dict) and isinstance(v, dict):
340-
LOGGER.debug("Recursively updating config %s", k)
341-
merge_config(into, v, subschema, source=source, overwrite=overwrite)
351+
LOGGER.debug("Recursively updating config %s", key_so_far + k)
352+
merge_config(into, v, subschema, key_so_far=f"{key_so_far}{k}.",
353+
source=source, overwrite=overwrite)
342354
else:
343355
# Source isn't recursing, so let's ignore
344356
# Should have been validated earlier
345-
LOGGER.warn("Invalid configuration key %s in %s", k, source)
357+
LOGGER.warn("Invalid configuration key %s in %s", key_so_far + k, source)
346358
continue
347359

348-
_, merge, *_ = subschema
349-
if not merge or overwrite:
350-
LOGGER.debug("Updating config %s from %r to %r", k, into, v)
351-
into_cfg[k] = v
360+
_, merge, *opts = subschema
361+
362+
try:
363+
into = into_cfg[k]
364+
except KeyError:
365+
into = None
352366
else:
353-
v2 = merge(into, v)
354-
LOGGER.debug("Updating config %s from %r to %r", k, into, v2)
355-
into_cfg[k] = v2
367+
if merge and not overwrite:
368+
v = merge(into, v)
369+
370+
if v != into:
371+
if "sensitive" in opts:
372+
LOGGER.debug("Updating config %s", key_so_far + k)
373+
elif "uri" in opts:
374+
from .urlutils import sanitise_url
375+
LOGGER.debug("Updating config %s to %r", key_so_far + k, sanitise_url(v))
376+
else:
377+
LOGGER.debug("Updating config %s to %r", key_so_far + k, v)
378+
into_cfg[k] = v
379+
380+
381+
def sanitise_config(config, schema):
382+
clean = {}
383+
for k, v in config.items():
384+
subschema = schema.get(k)
385+
if not subschema:
386+
LOGGER.debug("Omitting %s from config output as it is not "
387+
"in the schema", k)
388+
continue
389+
if isinstance(subschema, dict):
390+
if not isinstance(v, dict):
391+
LOGGER.debug("Omitting %s from config output as the schema "
392+
"wants an object and the config has a value.", k)
393+
continue
394+
clean[k] = sanitise_config(v, subschema)
395+
continue
396+
kind, _, *opts = subschema
397+
if "sensitive" in opts:
398+
LOGGER.debug("Omitting %s from config output as it is "
399+
"marked sensitive.", k)
400+
continue
401+
if kind:
402+
v = kind(v)
403+
if "uri" in opts:
404+
from .urlutils import sanitise_url
405+
v = sanitise_url(v)
406+
clean[k] = v
407+
return clean

‎src/manage/exceptions.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,16 @@ def __init__(self, message, prefix=None):
5151

5252

5353
class InvalidConfigurationError(ValueError):
54-
def __init__(self, file=None, argument=None, value=None):
54+
def __init__(self, file=None, argument=None, value=None, opts=None):
55+
if value and opts:
56+
if "sensitive" in opts:
57+
value = "*" * len(value)
58+
elif "uri" in opts:
59+
try:
60+
from .urlutils import sanitise_url
61+
value = sanitise_url(value)
62+
except Exception:
63+
value = ""
5564
if value:
5665
msg = f"Invalid configuration value {value!r} for key {argument} in {file}"
5766
elif argument:

‎src/manage/install_command.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
from .tagutils import install_matches_any, tag_or_range
1616
from .urlutils import (
1717
sanitise_url,
18-
urlopen as _urlopen,
1918
urlretrieve as _urlretrieve,
2019
IndexDownloader,
2120
)
@@ -48,7 +47,7 @@ def _expand_versions_by_tag(versions):
4847
yield {**v, "tag": t}
4948

5049

51-
def select_package(index_downloader, tag, platform=None, *, urlopen=_urlopen, by_id=False, allow_pre=True):
50+
def select_package(index_downloader, tag, platform=None, *, by_id=False, allow_pre=True):
5251
"""Finds suitable package from index.json that looks like:
5352
{"versions": [
5453
{"id": ..., "company": ..., "tag": ..., "url": ..., "hash": {"sha256": hexdigest}},
@@ -96,7 +95,7 @@ def select_package(index_downloader, tag, platform=None, *, urlopen=_urlopen, by
9695
raise RuntimeError("End of select_package reached")
9796

9897

99-
def download_package(cmd, install, dest, cache, *, on_progress=None, urlopen=_urlopen, urlretrieve=_urlretrieve):
98+
def download_package(cmd, install, dest, cache, *, on_progress=None, urlretrieve=_urlretrieve):
10099
LOGGER.debug("Starting download package %s to %s", sanitise_url(install["url"]), dest)
101100

102101
if not cmd.force and dest.is_file():
@@ -130,7 +129,7 @@ def _find_creds(url):
130129
if auth:
131130
LOGGER.debug("Found credentials in URL or configured source.")
132131
return auth
133-
auth = os.getenv("PYMANAGER_USERNAME", ""), os.getenv("PYMANAGER_PASSWORD", "")
132+
auth = cmd.username, (cmd.password or "")
134133
if auth[0]:
135134
LOGGER.debug("Found credentials in environment.")
136135
return auth
@@ -143,6 +142,7 @@ def _find_creds(url):
143142
on_progress=on_progress,
144143
on_auth_request=_find_creds,
145144
on_cancel=lambda: cmd.ask_yn("Abort download?"),
145+
proxy_info=cmd,
146146
)
147147
LOGGER.debug("Downloaded to %s", dest)
148148
return dest

‎src/manage/list_command.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,9 +225,16 @@ def list_formats(cmd, installs):
225225
LOGGER.print(f"{k:<{max_key_width}} {doc}", always=True)
226226

227227

228-
def list_config(cmd, installs):
228+
def list_config(cmd, installs, schema=None):
229229
"List the current config"
230-
LOGGER.print(json.dumps(cmd.config, default=str), always=True)
230+
from .config import sanitise_config
231+
if schema is None:
232+
from .commands import CONFIG_SCHEMA as schema
233+
234+
LOGGER.print(
235+
json.dumps(sanitise_config(cmd.config, schema), default=str),
236+
always=True
237+
)
231238

232239

233240
def format_legacy(cmd, installs, paths=False):

0 commit comments

Comments
 (0)