From ea0bb06087dfce079473f911d39edd8e1882130e Mon Sep 17 00:00:00 2001 From: Peter Giacomo Lombardo Date: Tue, 20 Nov 2018 17:21:24 +0100 Subject: [PATCH 1/7] Add support for HTTP secrets (and tests) --- instana/agent.py | 8 +++- instana/singletons.py | 2 +- instana/util.py | 53 +++++++++++++++++++-- tests/test_secrets.py | 104 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 tests/test_secrets.py diff --git a/instana/agent.py b/instana/agent.py index e8a59e97..c5e2aadb 100644 --- a/instana/agent.py +++ b/instana/agent.py @@ -34,6 +34,8 @@ class Agent(object): last_fork_check = None _boot_pid = os.getpid() extra_headers = None + secrets_matcher = None + secrets_list = None client = requests.Session() def __init__(self): @@ -69,7 +71,7 @@ def can_send(self): self.handle_fork() return False - if (self.fsm.fsm.current == "good2go"): + if self.fsm.fsm.current == "good2go": return True return False @@ -82,6 +84,10 @@ def set_from(self, json_string): res_data = json.loads(raw_json) + if "secrets" in res_data: + self.secrets_matcher = res_data['secrets']['matcher'] + self.secrets_list = res_data['secrets']['list'] + if "extraHeaders" in res_data: self.extra_headers = res_data['extraHeaders'] logger.info("Will also capture these custom headers: %s", self.extra_headers) diff --git a/instana/singletons.py b/instana/singletons.py index 1de856c2..a553fa16 100644 --- a/instana/singletons.py +++ b/instana/singletons.py @@ -17,5 +17,5 @@ # tracer = InstanaTracer() -# Set ourselves as the tracer. +# Set ourselves as the tracer. opentracing.tracer = tracer diff --git a/instana/util.py b/instana/util.py index e0b1c33c..c65a2901 100644 --- a/instana/util.py +++ b/instana/util.py @@ -8,9 +8,11 @@ import time import pkg_resources +from urllib import parse from .log import logger + if sys.version_info.major is 2: string_types = basestring else: @@ -28,7 +30,7 @@ def generate_id(): global _current_pid pid = os.getpid() - if (_current_pid != pid): + if _current_pid != pid: _current_pid = pid _rnd.seed(int(1000000 * time.time()) ^ pid) return _rnd.randint(-9223372036854775808, 9223372036854775807) @@ -41,8 +43,8 @@ def id_to_header(id): if not isinstance(id, int): return BAD_ID_HEADER - byteString = struct.pack('>q', id) - return str(binascii.hexlify(byteString).decode('UTF-8').lstrip('0')) + byte_string = struct.pack('>q', id) + return str(binascii.hexlify(byte_string).decode('UTF-8').lstrip('0')) except Exception as e: logger.debug(e) return BAD_ID_HEADER @@ -75,8 +77,8 @@ def to_json(obj): def package_version(): + version = "" try: - version = "" version = pkg_resources.get_distribution('instana').version except pkg_resources.DistributionNotFound: version = 'unknown' @@ -84,6 +86,49 @@ def package_version(): return version +def strip_secrets(qp, matcher, kwlist): + """ + This function will scrub the secrets from a query param string based on the passed in matcher and kwlist. + + :param qp: a string representing the query params in URL form (unencoded) + :param matcher: the matcher to use + :param kwlist: the list of keywords to match + :return: a scrubbed query param string + """ + params = parse.parse_qs(qp, keep_blank_values=True) + redacted = [''] + + if matcher == 'equals-ignore-case': + for keyword in kwlist: + for key in params.keys(): + if key.lower() == keyword.lower(): + params[key] = redacted + elif matcher == 'equals': + for keyword in kwlist: + if keyword in params: + params[keyword] = redacted + elif matcher == 'contains-ignore-case': + for keyword in kwlist: + for key in params.keys(): + if keyword.lower() in key.lower(): + params[key] = redacted + elif matcher == 'contains': + for keyword in kwlist: + for key in params.keys(): + if keyword in key: + params[key] = redacted + elif matcher == 'regex': + for regexp in kwlist: + for key in params.keys(): + if re.match(regexp, key): + params[key] = redacted + else: + logger.debug("strip_secrets: unknown matcher") + + result = parse.urlencode(params, doseq=True) + return parse.unquote(result) + + def get_py_source(file): """ Retrieves and returns the source code for any Python diff --git a/tests/test_secrets.py b/tests/test_secrets.py new file mode 100644 index 00000000..d341d219 --- /dev/null +++ b/tests/test_secrets.py @@ -0,0 +1,104 @@ +from __future__ import absolute_import + +import unittest + +from instana.singletons import agent +from instana.util import strip_secrets + + +class TestSecrets(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_equals_ignore_case(self): + matcher = 'equals-ignore-case' + kwlist = ['two'] + + query_params = "one=1&Two=two&THREE=&4='+'&five='okyeah'" + + stripped = strip_secrets(query_params, matcher, kwlist) + + self.assertEquals(stripped, "one=1&Two=&THREE=&4='+'&five='okyeah'") + + def test_equals(self): + matcher = 'equals' + kwlist = ['Two'] + + query_params = "one=1&Two=two&THREE=&4='+'&five='okyeah'" + + stripped = strip_secrets(query_params, matcher, kwlist) + + self.assertEquals(stripped, "one=1&Two=&THREE=&4='+'&five='okyeah'") + + def test_equals_no_match(self): + matcher = 'equals' + kwlist = ['two'] + + query_params = "one=1&Two=two&THREE=&4='+'&five='okyeah'" + + stripped = strip_secrets(query_params, matcher, kwlist) + + self.assertEquals(stripped, "one=1&Two=two&THREE=&4='+'&five='okyeah'") + + def test_contains_ignore_case(self): + matcher = 'contains-ignore-case' + kwlist = ['FI'] + + query_params = "one=1&Two=two&THREE=&4='+'&five='okyeah'" + + stripped = strip_secrets(query_params, matcher, kwlist) + + self.assertEquals(stripped, "one=1&Two=two&THREE=&4='+'&five=") + + def test_contains_ignore_case_no_match(self): + matcher = 'contains-ignore-case' + kwlist = ['XXXXXX'] + + query_params = "one=1&Two=two&THREE=&4='+'&five='okyeah'" + + stripped = strip_secrets(query_params, matcher, kwlist) + + self.assertEquals(stripped, "one=1&Two=two&THREE=&4='+'&five='okyeah'") + + def test_contains(self): + matcher = 'contains' + kwlist = ['fi'] + + query_params = "one=1&Two=two&THREE=&4='+'&five='okyeah'" + + stripped = strip_secrets(query_params, matcher, kwlist) + + self.assertEquals(stripped, "one=1&Two=two&THREE=&4='+'&five=") + + def test_contains_no_match(self): + matcher = 'contains' + kwlist = ['XXXXXX'] + + query_params = "one=1&Two=two&THREE=&4='+'&five='okyeah'" + + stripped = strip_secrets(query_params, matcher, kwlist) + + self.assertEquals(stripped, "one=1&Two=two&THREE=&4='+'&five='okyeah'") + + def test_regex(self): + matcher = 'regex' + kwlist = ['\d'] + + query_params = "one=1&Two=two&THREE=&4='+'&five='okyeah'" + + stripped = strip_secrets(query_params, matcher, kwlist) + + self.assertEquals(stripped, "one=1&Two=two&THREE=&4=&five='okyeah'") + + def test_regex_no_match(self): + matcher = 'regex' + kwlist = ['\d\d\d'] + + query_params = "one=1&Two=two&THREE=&4='+'&five='okyeah'" + + stripped = strip_secrets(query_params, matcher, kwlist) + + self.assertEquals(stripped, "one=1&Two=two&THREE=&4='+'&five='okyeah'") From 0e362729d78f9985af3b3eac8677c4e729a23d34 Mon Sep 17 00:00:00 2001 From: Peter Giacomo Lombardo Date: Wed, 21 Nov 2018 11:41:06 +0100 Subject: [PATCH 2/7] Set defaults for secrets in sensor --- instana/agent.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/instana/agent.py b/instana/agent.py index c5e2aadb..3af9b16b 100644 --- a/instana/agent.py +++ b/instana/agent.py @@ -34,8 +34,8 @@ class Agent(object): last_fork_check = None _boot_pid = os.getpid() extra_headers = None - secrets_matcher = None - secrets_list = None + secrets_matcher = 'contains-ignore-case' + secrets_list = ['key', 'password', 'secret'] client = requests.Session() def __init__(self): From abab2a62fe2035fb95562fae8a6ccad49cdf4250 Mon Sep 17 00:00:00 2001 From: Peter Giacomo Lombardo Date: Wed, 21 Nov 2018 11:42:10 +0100 Subject: [PATCH 3/7] Apply secrets to HTTP instrumentation --- instana/instrumentation/django/middleware.py | 4 +++- instana/instrumentation/urllib3.py | 8 ++++++-- instana/wsgi.py | 4 +++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/instana/instrumentation/django/middleware.py b/instana/instrumentation/django/middleware.py index 9b568366..b2756fd6 100644 --- a/instana/instrumentation/django/middleware.py +++ b/instana/instrumentation/django/middleware.py @@ -8,6 +8,7 @@ from ...log import logger from ...singletons import agent, tracer +from ...util import strip_secrets DJ_INSTANA_MIDDLEWARE = 'instana.instrumentation.django.middleware.InstanaMiddleware' @@ -44,7 +45,8 @@ def process_request(self, request): if 'PATH_INFO' in env: request.iscope.span.set_tag(ext.HTTP_URL, env['PATH_INFO']) if 'QUERY_STRING' in env and len(env['QUERY_STRING']): - request.iscope.span.set_tag("http.params", env['QUERY_STRING']) + scrubbed_params = strip_secrets(env['QUERY_STRING'], agent.secrets_matcher, agent.secrets_list) + request.iscope.span.set_tag("http.params", scrubbed_params) if 'HTTP_HOST' in env: request.iscope.span.set_tag("http.host", env['HTTP_HOST']) except Exception: diff --git a/instana/instrumentation/urllib3.py b/instana/instrumentation/urllib3.py index c4b5a877..e9c448a3 100644 --- a/instana/instrumentation/urllib3.py +++ b/instana/instrumentation/urllib3.py @@ -5,7 +5,8 @@ import wrapt from ..log import logger -from ..singletons import tracer +from ..singletons import agent, tracer +from ..util import strip_secrets try: import urllib3 # noqa @@ -20,13 +21,16 @@ def collect(instance, args, kwargs): if args is not None and len(args) is 2: kvs['method'] = args[0] - kvs['path'] = args[1] + kvs['path'] = strip_secrets(args[1], agent.secrets_matcher, agent.secrets_list) else: kvs['method'] = kwargs.get('method') kvs['path'] = kwargs.get('path') if kvs['path'] is None: kvs['path'] = kwargs.get('url') + # Strip any secrets from potential query params + kvs['path'] = strip_secrets(kvs['path'], agent.secrets_matcher, agent.secrets_list) + if type(instance) is urllib3.connectionpool.HTTPSConnectionPool: kvs['url'] = 'https://%s:%d%s' % (kvs['host'], kvs['port'], kvs['path']) else: diff --git a/instana/wsgi.py b/instana/wsgi.py index 1878dfef..2edb5a35 100644 --- a/instana/wsgi.py +++ b/instana/wsgi.py @@ -4,6 +4,7 @@ import opentracing.ext.tags as tags from .singletons import agent, tracer +from .util import strip_secrets class iWSGIMiddleware(object): @@ -47,7 +48,8 @@ def new_start_response(status, headers, exc_info=None): if 'PATH_INFO' in env: self.scope.span.set_tag(tags.HTTP_URL, env['PATH_INFO']) if 'QUERY_STRING' in env and len(env['QUERY_STRING']): - self.scope.span.set_tag("http.params", env['QUERY_STRING']) + scrubbed_params = strip_secrets(env['QUERY_STRING'], agent.secrets_matcher, agent.secrets_list) + self.scope.span.set_tag("http.params", scrubbed_params) if 'REQUEST_METHOD' in env: self.scope.span.set_tag(tags.HTTP_METHOD, env['REQUEST_METHOD']) if 'HTTP_HOST' in env: From 083d5ac64f7e9e42e18f29eabedeab692ff4d3be Mon Sep 17 00:00:00 2001 From: Peter Giacomo Lombardo Date: Wed, 21 Nov 2018 11:42:36 +0100 Subject: [PATCH 4/7] Report separated params --- instana/json_span.py | 1 + instana/recorder.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/instana/json_span.py b/instana/json_span.py index 522b1248..b927a349 100644 --- a/instana/json_span.py +++ b/instana/json_span.py @@ -43,6 +43,7 @@ def __init__(self, **kwds): class HttpData(object): host = None url = None + params = None status = 0 method = None path_tpl = None diff --git a/instana/recorder.py b/instana/recorder.py index bc8b56e5..de9b941f 100644 --- a/instana/recorder.py +++ b/instana/recorder.py @@ -104,6 +104,7 @@ def build_registered_span(self, span): if span.operation_name in self.http_spans: data.http = HttpData(host=self.get_http_host_name(span), url=span.tags.pop(ext.HTTP_URL, None), + params=span.tags.pop('http.params', None), method=span.tags.pop(ext.HTTP_METHOD, None), status=span.tags.pop(ext.HTTP_STATUS_CODE, None), path_tpl=span.tags.pop("http.path_tpl", None), @@ -129,7 +130,6 @@ def build_registered_span(self, span): url=span.tags.pop('sqlalchemy.url', None), err=span.tags.pop('sqlalchemy.err', None)) - if span.operation_name == "soap": data.soap = SoapData(action=span.tags.pop('soap.action', None)) From daec4d0f5381459c98482a5d53aab46803e6f355 Mon Sep 17 00:00:00 2001 From: Peter Giacomo Lombardo Date: Wed, 21 Nov 2018 11:44:49 +0100 Subject: [PATCH 5/7] More safeties & test cases --- instana/util.py | 29 ++++++++++++++++++++++++++-- tests/test_secrets.py | 30 +++++++++++++++++++++++++++++ tests/test_wsgi.py | 45 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/instana/util.py b/instana/util.py index c65a2901..663baff7 100644 --- a/instana/util.py +++ b/instana/util.py @@ -90,12 +90,32 @@ def strip_secrets(qp, matcher, kwlist): """ This function will scrub the secrets from a query param string based on the passed in matcher and kwlist. + blah=1&secret=password&valid=true will result in blah=1&secret=&valid=true + + You can even pass in path query combinations: + + /signup?blah=1&secret=password&valid=true will result in /signup?blah=1&secret=&valid=true + :param qp: a string representing the query params in URL form (unencoded) :param matcher: the matcher to use :param kwlist: the list of keywords to match :return: a scrubbed query param string """ - params = parse.parse_qs(qp, keep_blank_values=True) + path = None + + if qp is None: + return '' + + # If there are no key=values, then just return + if not '=' in qp: + return qp + + if '?' in qp: + path, query = qp.split('?') + else: + query = qp + + params = parse.parse_qs(query, keep_blank_values=True) redacted = [''] if matcher == 'equals-ignore-case': @@ -126,7 +146,12 @@ def strip_secrets(qp, matcher, kwlist): logger.debug("strip_secrets: unknown matcher") result = parse.urlencode(params, doseq=True) - return parse.unquote(result) + query = parse.unquote(result) + + if path: + query = path + '?' + query + + return query def get_py_source(file): diff --git a/tests/test_secrets.py b/tests/test_secrets.py index d341d219..b31538bc 100644 --- a/tests/test_secrets.py +++ b/tests/test_secrets.py @@ -102,3 +102,33 @@ def test_regex_no_match(self): stripped = strip_secrets(query_params, matcher, kwlist) self.assertEquals(stripped, "one=1&Two=two&THREE=&4='+'&five='okyeah'") + + def test_equals_with_path_component(self): + matcher = 'equals' + kwlist = ['Two'] + + query_params = "/signup?one=1&Two=two&THREE=&4='+'&five='okyeah'" + + stripped = strip_secrets(query_params, matcher, kwlist) + + self.assertEquals(stripped, "/signup?one=1&Two=&THREE=&4='+'&five='okyeah'") + + def test_equals_with_full_url(self): + matcher = 'equals' + kwlist = ['Two'] + + query_params = "http://www.x.org/signup?one=1&Two=two&THREE=&4='+'&five='okyeah'" + + stripped = strip_secrets(query_params, matcher, kwlist) + + self.assertEquals(stripped, "http://www.x.org/signup?one=1&Two=&THREE=&4='+'&five='okyeah'") + + def test_equals_with_none(self): + matcher = 'equals' + kwlist = ['Two'] + + query_params = None + + stripped = strip_secrets(query_params, matcher, kwlist) + + self.assertEqual('', stripped) diff --git a/tests/test_wsgi.py b/tests/test_wsgi.py index 9b886588..b356bf4e 100644 --- a/tests/test_wsgi.py +++ b/tests/test_wsgi.py @@ -121,7 +121,6 @@ def test_complex_request(self): self.assertIsNotNone(wsgi_span.stack) self.assertEqual(2, len(wsgi_span.stack)) - def test_custom_header_capture(self): # Hack together a manual custom headers list agent.extra_headers = [u'X-Capture-This', u'X-Capture-That'] @@ -171,8 +170,50 @@ def test_custom_header_capture(self): self.assertIsNotNone(wsgi_span.stack) self.assertEqual(2, len(wsgi_span.stack)) - self.assertEqual(True, "http.X-Capture-This" in wsgi_span.data.custom.__dict__['tags']) self.assertEqual("this", wsgi_span.data.custom.__dict__['tags']["http.X-Capture-This"]) self.assertEqual(True, "http.X-Capture-That" in wsgi_span.data.custom.__dict__['tags']) self.assertEqual("that", wsgi_span.data.custom.__dict__['tags']["http.X-Capture-That"]) + + def test_secret_scrubbing(self): + with tracer.start_active_span('test'): + response = self.http.request('GET', 'http://127.0.0.1:5000/?secret=shhh') + + spans = self.recorder.queued_spans() + + self.assertEqual(3, len(spans)) + self.assertIsNone(tracer.active_span) + + wsgi_span = spans[0] + urllib3_span = spans[1] + test_span = spans[2] + + assert response + self.assertEqual(200, response.status) + + # Same traceId + self.assertEqual(test_span.t, urllib3_span.t) + self.assertEqual(urllib3_span.t, wsgi_span.t) + + # Parent relationships + self.assertEqual(urllib3_span.p, test_span.s) + self.assertEqual(wsgi_span.p, urllib3_span.s) + + # Error logging + self.assertFalse(test_span.error) + self.assertIsNone(test_span.ec) + self.assertFalse(urllib3_span.error) + self.assertIsNone(urllib3_span.ec) + self.assertFalse(wsgi_span.error) + self.assertIsNone(wsgi_span.ec) + + # wsgi + self.assertEqual("wsgi", wsgi_span.n) + self.assertEqual('127.0.0.1:5000', wsgi_span.data.http.host) + self.assertEqual('/', wsgi_span.data.http.url) + self.assertEqual('secret=', wsgi_span.data.http.params) + self.assertEqual('GET', wsgi_span.data.http.method) + self.assertEqual('200', wsgi_span.data.http.status) + self.assertIsNone(wsgi_span.data.http.error) + self.assertIsNotNone(wsgi_span.stack) + self.assertEqual(2, len(wsgi_span.stack)) From 8db9d5c0d65090432a7ba604bbe7cab922645521 Mon Sep 17 00:00:00 2001 From: Peter Giacomo Lombardo Date: Wed, 21 Nov 2018 11:57:14 +0100 Subject: [PATCH 6/7] Add safeties against bad matchers and keyword lists --- instana/util.py | 5 +++++ tests/test_secrets.py | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/instana/util.py b/instana/util.py index 663baff7..235ae35b 100644 --- a/instana/util.py +++ b/instana/util.py @@ -106,6 +106,10 @@ def strip_secrets(qp, matcher, kwlist): if qp is None: return '' + if type(kwlist) is not list: + logger.debug("strip_secrets: bad keyword list") + return qp + # If there are no key=values, then just return if not '=' in qp: return qp @@ -144,6 +148,7 @@ def strip_secrets(qp, matcher, kwlist): params[key] = redacted else: logger.debug("strip_secrets: unknown matcher") + return qp result = parse.urlencode(params, doseq=True) query = parse.unquote(result) diff --git a/tests/test_secrets.py b/tests/test_secrets.py index b31538bc..309fe8f1 100644 --- a/tests/test_secrets.py +++ b/tests/test_secrets.py @@ -132,3 +132,23 @@ def test_equals_with_none(self): stripped = strip_secrets(query_params, matcher, kwlist) self.assertEqual('', stripped) + + def test_bad_matcher(self): + matcher = 'BADCAFE' + kwlist = ['Two'] + + query_params = "one=1&Two=two&THREE=&4='+'&five='okyeah'" + + stripped = strip_secrets(query_params, matcher, kwlist) + + self.assertEquals(stripped, "one=1&Two=two&THREE=&4='+'&five='okyeah'") + + def test_bad_kwlist(self): + matcher = 'equals' + kwlist = None + + query_params = "one=1&Two=two&THREE=&4='+'&five='okyeah'" + + stripped = strip_secrets(query_params, matcher, kwlist) + + self.assertEquals(stripped, "one=1&Two=two&THREE=&4='+'&five='okyeah'") From 74a5088d56f6679b8a8fc19a2c454d0fcaac485f Mon Sep 17 00:00:00 2001 From: Peter Giacomo Lombardo Date: Wed, 21 Nov 2018 12:19:15 +0100 Subject: [PATCH 7/7] Add exception handling --- instana/util.py | 110 ++++++++++++++++++++++++------------------------ 1 file changed, 56 insertions(+), 54 deletions(-) diff --git a/instana/util.py b/instana/util.py index 235ae35b..232497b3 100644 --- a/instana/util.py +++ b/instana/util.py @@ -103,61 +103,63 @@ def strip_secrets(qp, matcher, kwlist): """ path = None - if qp is None: - return '' - - if type(kwlist) is not list: - logger.debug("strip_secrets: bad keyword list") - return qp - - # If there are no key=values, then just return - if not '=' in qp: - return qp - - if '?' in qp: - path, query = qp.split('?') - else: - query = qp - - params = parse.parse_qs(query, keep_blank_values=True) - redacted = [''] - - if matcher == 'equals-ignore-case': - for keyword in kwlist: - for key in params.keys(): - if key.lower() == keyword.lower(): - params[key] = redacted - elif matcher == 'equals': - for keyword in kwlist: - if keyword in params: - params[keyword] = redacted - elif matcher == 'contains-ignore-case': - for keyword in kwlist: - for key in params.keys(): - if keyword.lower() in key.lower(): - params[key] = redacted - elif matcher == 'contains': - for keyword in kwlist: - for key in params.keys(): - if keyword in key: - params[key] = redacted - elif matcher == 'regex': - for regexp in kwlist: - for key in params.keys(): - if re.match(regexp, key): - params[key] = redacted - else: - logger.debug("strip_secrets: unknown matcher") - return qp - - result = parse.urlencode(params, doseq=True) - query = parse.unquote(result) - - if path: - query = path + '?' + query - - return query + try: + if qp is None: + return '' + + if type(kwlist) is not list: + logger.debug("strip_secrets: bad keyword list") + return qp + + # If there are no key=values, then just return + if not '=' in qp: + return qp + + if '?' in qp: + path, query = qp.split('?') + else: + query = qp + + params = parse.parse_qs(query, keep_blank_values=True) + redacted = [''] + + if matcher == 'equals-ignore-case': + for keyword in kwlist: + for key in params.keys(): + if key.lower() == keyword.lower(): + params[key] = redacted + elif matcher == 'equals': + for keyword in kwlist: + if keyword in params: + params[keyword] = redacted + elif matcher == 'contains-ignore-case': + for keyword in kwlist: + for key in params.keys(): + if keyword.lower() in key.lower(): + params[key] = redacted + elif matcher == 'contains': + for keyword in kwlist: + for key in params.keys(): + if keyword in key: + params[key] = redacted + elif matcher == 'regex': + for regexp in kwlist: + for key in params.keys(): + if re.match(regexp, key): + params[key] = redacted + else: + logger.debug("strip_secrets: unknown matcher") + return qp + + result = parse.urlencode(params, doseq=True) + query = parse.unquote(result) + + if path: + query = path + '?' + query + return query + except: + logger.debug("strip_secrets", exc_info=True) def get_py_source(file): """