diff --git a/changelogs/fragments/mask_url.yml b/changelogs/fragments/mask_url.yml new file mode 100644 index 00000000000..7c3564984d9 --- /dev/null +++ b/changelogs/fragments/mask_url.yml @@ -0,0 +1,2 @@ +minor_changes: + - mask_url function in module_utils to allow for masking of auth data embedded in urls. diff --git a/lib/ansible/module_utils/urls.py b/lib/ansible/module_utils/urls.py index bc9cebd9ad9..2f0e58426eb 100644 --- a/lib/ansible/module_utils/urls.py +++ b/lib/ansible/module_utils/urls.py @@ -45,6 +45,7 @@ import traceback import types # pylint: disable=unused-import import urllib.error import urllib.request + from contextlib import contextmanager from functools import partial from http import cookiejar @@ -314,6 +315,24 @@ class ParseResultDottedDict(dict): return [self.get(k, None) for k in ('scheme', 'netloc', 'path', 'params', 'query', 'fragment')] +def mask_url(url: str) -> str: + """ + Safely display a url by masking confidential data + from a string or the result from urlparse/split + """ + if (parsed_url := urlparse(url)) and not parsed_url.username: + return url + + netloc: str + mask = '****' + if parsed_url.password: + netloc = parsed_url.netloc.replace(f'{parsed_url.username}:{parsed_url.password}@', f'{mask}:{mask}@') + else: + netloc = parsed_url.netloc.replace(f'{parsed_url.username}@', f'{mask}@') + + return urlunparse(parsed_url._replace(netloc=netloc)) + + def generic_urlparse(parts): """ Returns a dictionary of url parts as parsed by urlparse, diff --git a/test/units/module_utils/urls/test_mask_url.py b/test/units/module_utils/urls/test_mask_url.py new file mode 100644 index 00000000000..2a388d87f5c --- /dev/null +++ b/test/units/module_utils/urls/test_mask_url.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# (c) 2026 The Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +import pytest + +from ansible.module_utils.urls import mask_url + + +# for test data use 'secret' as part of any parameter that requires masking, avoid elsewhere +@pytest.mark.parametrize( + 'url, wanted', + ( + ('http://nothingtoseehere.com', ('nothingtoseehere.com', 'http')), + ('http://nothingtoseehere.com:80/stuff.asp?he=no', ('http://nothingtoseehere.com:80/stuff.asp?he=no',)), + ('http://nothingtoseehere.com:80?password=intheclear&user=wrongbutweignore', ('wrongbut', 'intheclear', 'password')), + ('https://secretuser@hideme.com/index.html', ('hideme.com', 'index.html', '*')), + ('https://secretuser@hideme.com/index.html?token=nothidden&user=alsonothidden', ('token', 'nothidden', 'alsonothidden', 'user')), + ('https://secretuser:secretpass@hideme.com/randomfile.html', ('randomfile.html')), + ('https://secretuser:secretpass@hideme.com:443/protected.html', ('protected.html', '443')), + ('ftp://secretuser:secretpass@files.insecure/subdir/intheclear.txt', ('subdir', 'intheclear.txt', 'ftp', 'files.insecure')), + ('sftp://secretuser:secretpass@files.secure/subdir2/encrypted', ('encrypted', 'sftp')), + ('ftps://secretuser:secretpass@file.secure/yolo.asc', ('yolo.asc', 'file.secure')), + ('ftps://file.server/yolo.asc', ('yolo.asc')), + ('ftps://secretuser:secretsecret@file.server/yolo.asc', ('file.server/yolo.asc')), + ) +) +def test_mask_url(url, wanted): + + masked = mask_url(url) + assert 'secret' not in masked + + for notmasked in wanted: + assert notmasked in masked