Redact specific url query string values and url credentials in instrumentations (#3508)

* Updated the instrumentation with aiohttp-server tests for url redaction

* Updated the aiohttp-server implementation and the query redaction logic

* Updated changelog and moved change to unreleased. Updated test files with license header

* Improved formatting

* Fixed failing tests

* Fixed ruff

* Update util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py

Co-authored-by: Emídio Neto <9735060+emdneto@users.noreply.github.com>

---------

Co-authored-by: Emídio Neto <9735060+emdneto@users.noreply.github.com>
This commit is contained in:
rads-1996 2025-07-01 05:26:47 -07:00 committed by GitHub
parent 6977da3893
commit b69ebb7224
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 363 additions and 63 deletions

View File

@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `opentelemetry-resource-detector-containerid`: make it more quiet on platforms without cgroups - `opentelemetry-resource-detector-containerid`: make it more quiet on platforms without cgroups
([#3579](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3579)) ([#3579](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3579))
### Added
- `opentelemetry-util-http` Added support for redacting specific url query string values and url credentials in instrumentations
([#3508](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3508))
## Version 1.34.0/0.55b0 (2025-06-04) ## Version 1.34.0/0.55b0 (2025-06-04)
### Fixed ### Fixed

View File

@ -135,7 +135,7 @@ from opentelemetry.semconv.metrics.http_metrics import (
) )
from opentelemetry.trace import Span, SpanKind, TracerProvider, get_tracer from opentelemetry.trace import Span, SpanKind, TracerProvider, get_tracer
from opentelemetry.trace.status import Status, StatusCode from opentelemetry.trace.status import Status, StatusCode
from opentelemetry.util.http import remove_url_credentials, sanitize_method from opentelemetry.util.http import redact_url, sanitize_method
_UrlFilterT = typing.Optional[typing.Callable[[yarl.URL], str]] _UrlFilterT = typing.Optional[typing.Callable[[yarl.URL], str]]
_RequestHookT = typing.Optional[ _RequestHookT = typing.Optional[
@ -311,9 +311,9 @@ def create_trace_config(
method = params.method method = params.method
request_span_name = _get_span_name(method) request_span_name = _get_span_name(method)
request_url = ( request_url = (
remove_url_credentials(trace_config_ctx.url_filter(params.url)) redact_url(trace_config_ctx.url_filter(params.url))
if callable(trace_config_ctx.url_filter) if callable(trace_config_ctx.url_filter)
else remove_url_credentials(str(params.url)) else redact_url(str(params.url))
) )
span_attributes = {} span_attributes = {}

View File

@ -762,16 +762,16 @@ class TestAioHttpIntegration(TestBase):
) )
self.memory_exporter.clear() self.memory_exporter.clear()
def test_credential_removal(self): def test_remove_sensitive_params(self):
trace_configs = [aiohttp_client.create_trace_config()] trace_configs = [aiohttp_client.create_trace_config()]
app = HttpServerMock("test_credential_removal") app = HttpServerMock("test_remove_sensitive_params")
@app.route("/status/200") @app.route("/status/200")
def index(): def index():
return "hello" return "hello"
url = "http://username:password@localhost:5000/status/200" url = "http://username:password@localhost:5000/status/200?Signature=secret"
with app.run("localhost", 5000): with app.run("localhost", 5000):
with self.subTest(url=url): with self.subTest(url=url):
@ -793,7 +793,9 @@ class TestAioHttpIntegration(TestBase):
(StatusCode.UNSET, None), (StatusCode.UNSET, None),
{ {
HTTP_METHOD: "GET", HTTP_METHOD: "GET",
HTTP_URL: ("http://localhost:5000/status/200"), HTTP_URL: (
"http://REDACTED:REDACTED@localhost:5000/status/200?Signature=REDACTED"
),
HTTP_STATUS_CODE: int(HTTPStatus.OK), HTTP_STATUS_CODE: int(HTTPStatus.OK),
}, },
) )

View File

@ -72,7 +72,7 @@ from opentelemetry.semconv._incubating.attributes.net_attributes import (
) )
from opentelemetry.semconv.metrics import MetricInstruments from opentelemetry.semconv.metrics import MetricInstruments
from opentelemetry.trace.status import Status, StatusCode from opentelemetry.trace.status import Status, StatusCode
from opentelemetry.util.http import get_excluded_urls, remove_url_credentials from opentelemetry.util.http import get_excluded_urls, redact_url
_duration_attrs = [ _duration_attrs = [
HTTP_METHOD, HTTP_METHOD,
@ -148,6 +148,7 @@ def collect_request_attributes(request: web.Request) -> Dict:
request.url.port, request.url.port,
str(request.url), str(request.url),
) )
query_string = request.query_string query_string = request.query_string
if query_string and http_url: if query_string and http_url:
if isinstance(query_string, bytes): if isinstance(query_string, bytes):
@ -161,7 +162,7 @@ def collect_request_attributes(request: web.Request) -> Dict:
HTTP_ROUTE: _get_view_func(request), HTTP_ROUTE: _get_view_func(request),
HTTP_FLAVOR: f"{request.version.major}.{request.version.minor}", HTTP_FLAVOR: f"{request.version.major}.{request.version.minor}",
HTTP_TARGET: request.path, HTTP_TARGET: request.path,
HTTP_URL: remove_url_credentials(http_url), HTTP_URL: redact_url(http_url),
} }
http_method = request.method http_method = request.method

View File

@ -152,3 +152,46 @@ async def test_suppress_instrumentation(
await client.get("/test-path") await client.get("/test-path")
assert len(memory_exporter.get_finished_spans()) == 0 assert len(memory_exporter.get_finished_spans()) == 0
@pytest.mark.asyncio
async def test_remove_sensitive_params(tracer, aiohttp_server):
"""Test that sensitive information in URLs is properly redacted."""
_, memory_exporter = tracer
# Set up instrumentation
AioHttpServerInstrumentor().instrument()
# Create app with test route
app = aiohttp.web.Application()
async def handler(request):
return aiohttp.web.Response(text="hello")
app.router.add_get("/status/200", handler)
# Start the server
server = await aiohttp_server(app)
# Make request with sensitive data in URL
url = f"http://username:password@{server.host}:{server.port}/status/200?Signature=secret"
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
assert response.status == 200
assert await response.text() == "hello"
# Verify redaction in span attributes
spans = memory_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.attributes[HTTP_METHOD] == "GET"
assert span.attributes[HTTP_STATUS_CODE] == 200
assert (
span.attributes[HTTP_URL]
== f"http://{server.host}:{server.port}/status/200?Signature=REDACTED"
)
# Clean up
AioHttpServerInstrumentor().uninstrument()
memory_exporter.clear()

View File

@ -278,7 +278,7 @@ from opentelemetry.util.http import (
get_custom_headers, get_custom_headers,
normalise_request_header_name, normalise_request_header_name,
normalise_response_header_name, normalise_response_header_name,
remove_url_credentials, redact_url,
sanitize_method, sanitize_method,
) )
@ -375,7 +375,7 @@ def collect_request_attributes(
if _report_old(sem_conv_opt_in_mode): if _report_old(sem_conv_opt_in_mode):
_set_http_url( _set_http_url(
result, result,
remove_url_credentials(http_url), redact_url(http_url),
_StabilityMode.DEFAULT, _StabilityMode.DEFAULT,
) )
http_method = scope.get("method", "") http_method = scope.get("method", "")

View File

@ -1809,12 +1809,14 @@ class TestAsgiAttributes(unittest.TestCase):
otel_asgi.set_status_code(self.span, "Invalid Status Code") otel_asgi.set_status_code(self.span, "Invalid Status Code")
self.assertEqual(self.span.set_status.call_count, 1) self.assertEqual(self.span.set_status.call_count, 1)
def test_credential_removal(self): def test_remove_sensitive_params(self):
self.scope["server"] = ("username:password@mock", 80) self.scope["server"] = ("username:password@mock", 80)
self.scope["path"] = "/status/200" self.scope["path"] = "/status/200"
self.scope["query_string"] = b"X-Goog-Signature=1234567890"
attrs = otel_asgi.collect_request_attributes(self.scope) attrs = otel_asgi.collect_request_attributes(self.scope)
self.assertEqual( self.assertEqual(
attrs[SpanAttributes.HTTP_URL], "http://mock/status/200" attrs[SpanAttributes.HTTP_URL],
"http://REDACTED:REDACTED@mock/status/200?X-Goog-Signature=REDACTED",
) )
def test_collect_target_attribute_missing(self): def test_collect_target_attribute_missing(self):

View File

@ -259,7 +259,7 @@ from opentelemetry.semconv.metrics.http_metrics import (
from opentelemetry.trace import SpanKind, Tracer, TracerProvider, get_tracer from opentelemetry.trace import SpanKind, Tracer, TracerProvider, get_tracer
from opentelemetry.trace.span import Span from opentelemetry.trace.span import Span
from opentelemetry.trace.status import StatusCode from opentelemetry.trace.status import StatusCode
from opentelemetry.util.http import remove_url_credentials, sanitize_method from opentelemetry.util.http import redact_url, sanitize_method
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@ -313,7 +313,7 @@ def _extract_parameters(
# In httpx >= 0.20.0, handle_request receives a Request object # In httpx >= 0.20.0, handle_request receives a Request object
request: httpx.Request = args[0] request: httpx.Request = args[0]
method = request.method.encode() method = request.method.encode()
url = httpx.URL(remove_url_credentials(str(request.url))) url = httpx.URL(str(request.url))
headers = request.headers headers = request.headers
stream = request.stream stream = request.stream
extensions = request.extensions extensions = request.extensions
@ -382,7 +382,7 @@ def _apply_request_client_attributes_to_span(
) )
# http semconv transition: http.url -> url.full # http semconv transition: http.url -> url.full
_set_http_url(span_attributes, str(url), semconv) _set_http_url(span_attributes, redact_url(str(url)), semconv)
# Set HTTP method in metric labels # Set HTTP method in metric labels
_set_http_method( _set_http_method(

View File

@ -1301,12 +1301,26 @@ class TestSyncIntegration(BaseTestCases.BaseManualTest):
self.assert_span(num_spans=1) self.assert_span(num_spans=1)
self.assert_metrics(num_metrics=1) self.assert_metrics(num_metrics=1)
def test_credential_removal(self): def test_remove_sensitive_params(self):
new_url = "http://username:password@mock/status/200" new_url = "http://username:password@mock/status/200?sig=secret"
self.perform_request(new_url) self.perform_request(new_url)
span = self.assert_span() span = self.assert_span()
self.assertEqual(span.attributes[SpanAttributes.HTTP_URL], self.URL) actual_url = span.attributes[SpanAttributes.HTTP_URL]
if "@" in actual_url:
# If credentials are present, they must be redacted
self.assertEqual(
span.attributes[SpanAttributes.HTTP_URL],
"http://REDACTED:REDACTED@mock/status/200?sig=REDACTED",
)
else:
# If credentials are removed completely, the query string should still be redacted
self.assertIn(
"http://mock/status/200?sig=REDACTED",
actual_url,
f"Basic URL structure is incorrect: {actual_url}",
)
class TestAsyncIntegration(BaseTestCases.BaseManualTest): class TestAsyncIntegration(BaseTestCases.BaseManualTest):
@ -1373,12 +1387,24 @@ class TestAsyncIntegration(BaseTestCases.BaseManualTest):
self.assert_span(num_spans=2) self.assert_span(num_spans=2)
self.assert_metrics(num_metrics=1) self.assert_metrics(num_metrics=1)
def test_credential_removal(self): def test_remove_sensitive_params(self):
new_url = "http://username:password@mock/status/200" new_url = "http://username:password@mock/status/200?Signature=secret"
self.perform_request(new_url) self.perform_request(new_url)
span = self.assert_span() span = self.assert_span()
self.assertEqual(span.attributes[SpanAttributes.HTTP_URL], self.URL) actual_url = span.attributes[SpanAttributes.HTTP_URL]
if "@" in actual_url:
self.assertEqual(
span.attributes[SpanAttributes.HTTP_URL],
"http://REDACTED:REDACTED@mock/status/200?Signature=REDACTED",
)
else:
self.assertIn(
"http://mock/status/200?Signature=REDACTED",
actual_url,
f"If credentials are removed, the query string still should be redacted {actual_url}",
)
class TestSyncInstrumentationIntegration(BaseTestCases.BaseInstrumentorTest): class TestSyncInstrumentationIntegration(BaseTestCases.BaseInstrumentorTest):

View File

@ -147,7 +147,7 @@ from opentelemetry.util.http import (
ExcludeList, ExcludeList,
get_excluded_urls, get_excluded_urls,
parse_excluded_urls, parse_excluded_urls,
remove_url_credentials, redact_url,
sanitize_method, sanitize_method,
) )
from opentelemetry.util.http.httplib import set_ip_on_next_http_connection from opentelemetry.util.http.httplib import set_ip_on_next_http_connection
@ -232,7 +232,7 @@ def _instrument(
method = request.method method = request.method
span_name = get_default_span_name(method) span_name = get_default_span_name(method)
url = remove_url_credentials(request.url) url = redact_url(request.url)
span_attributes = {} span_attributes = {}
_set_http_method( _set_http_method(

View File

@ -686,12 +686,17 @@ class TestRequestsIntegration(RequestsIntegrationTestBase, TestBase):
return requests.get(url, timeout=5) return requests.get(url, timeout=5)
return session.get(url) return session.get(url)
def test_credential_removal(self): def test_remove_sensitive_params(self):
new_url = "http://username:password@mock/status/200" new_url = (
"http://username:password@mock/status/200?AWSAccessKeyId=secret"
)
self.perform_request(new_url) self.perform_request(new_url)
span = self.assert_span() span = self.assert_span()
self.assertEqual(span.attributes[HTTP_URL], self.URL) self.assertEqual(
span.attributes[HTTP_URL],
"http://REDACTED:REDACTED@mock/status/200?AWSAccessKeyId=REDACTED",
)
def test_if_headers_equals_none(self): def test_if_headers_equals_none(self):
result = requests.get(self.URL, headers=None, timeout=5) result = requests.get(self.URL, headers=None, timeout=5)

View File

@ -26,7 +26,7 @@ from opentelemetry.semconv._incubating.attributes.http_attributes import (
HTTP_URL, HTTP_URL,
) )
from opentelemetry.trace.status import Status, StatusCode from opentelemetry.trace.status import Status, StatusCode
from opentelemetry.util.http import remove_url_credentials from opentelemetry.util.http import redact_url
def _normalize_request(args, kwargs): def _normalize_request(args, kwargs):
@ -79,7 +79,7 @@ def fetch_async(
if span.is_recording(): if span.is_recording():
attributes = { attributes = {
HTTP_URL: remove_url_credentials(request.url), HTTP_URL: redact_url(request.url),
HTTP_METHOD: request.method, HTTP_METHOD: request.method,
} }
for key, value in attributes.items(): for key, value in attributes.items():
@ -165,7 +165,7 @@ def _finish_tracing_callback(
def _create_metric_attributes(response): def _create_metric_attributes(response):
metric_attributes = { metric_attributes = {
HTTP_STATUS_CODE: response.code, HTTP_STATUS_CODE: response.code,
HTTP_URL: remove_url_credentials(response.request.url), HTTP_URL: redact_url(response.request.url),
HTTP_METHOD: response.request.method, HTTP_METHOD: response.request.method,
} }

View File

@ -500,8 +500,8 @@ class TestTornadoInstrumentation(TornadoTest, WsgiTestBase):
set_global_response_propagator(orig) set_global_response_propagator(orig)
def test_credential_removal(self): def test_remove_sensitive_params(self):
app = HttpServerMock("test_credential_removal") app = HttpServerMock("test_remove_sensitive_params")
@app.route("/status/200") @app.route("/status/200")
def index(): def index():
@ -509,7 +509,7 @@ class TestTornadoInstrumentation(TornadoTest, WsgiTestBase):
with app.run("localhost", 5000): with app.run("localhost", 5000):
response = self.fetch( response = self.fetch(
"http://username:password@localhost:5000/status/200" "http://username:password@localhost:5000/status/200?Signature=secret"
) )
self.assertEqual(response.code, 200) self.assertEqual(response.code, 200)
@ -522,7 +522,7 @@ class TestTornadoInstrumentation(TornadoTest, WsgiTestBase):
self.assertSpanHasAttributes( self.assertSpanHasAttributes(
client, client,
{ {
HTTP_URL: "http://localhost:5000/status/200", HTTP_URL: "http://REDACTED:REDACTED@localhost:5000/status/200?Signature=REDACTED",
HTTP_METHOD: "GET", HTTP_METHOD: "GET",
HTTP_STATUS_CODE: 200, HTTP_STATUS_CODE: 200,
}, },

View File

@ -136,7 +136,7 @@ from opentelemetry.util.http import (
ExcludeList, ExcludeList,
get_excluded_urls, get_excluded_urls,
parse_excluded_urls, parse_excluded_urls,
remove_url_credentials, redact_url,
sanitize_method, sanitize_method,
) )
from opentelemetry.util.types import Attributes from opentelemetry.util.types import Attributes
@ -258,7 +258,7 @@ def _instrument(
span_name = _get_span_name(method) span_name = _get_span_name(method)
url = remove_url_credentials(url) url = redact_url(url)
data = getattr(request, "data", None) data = getattr(request, "data", None)
request_size = 0 if data is None else len(data) request_size = 0 if data is None else len(data)

View File

@ -512,14 +512,17 @@ class URLLibIntegrationTestBase(abc.ABC):
span = self.assert_span() span = self.assert_span()
self.assertEqual(span.status.status_code, StatusCode.ERROR) self.assertEqual(span.status.status_code, StatusCode.ERROR)
def test_credential_removal(self): def test_remove_sensitive_params(self):
url = "http://username:password@mock/status/200" url = "http://username:password@mock/status/200"
with self.assertRaises(Exception): with self.assertRaises(Exception):
self.perform_request(url) self.perform_request(url)
span = self.assert_span() span = self.assert_span()
self.assertEqual(span.attributes[SpanAttributes.HTTP_URL], self.URL) self.assertEqual(
span.attributes[SpanAttributes.HTTP_URL],
"http://REDACTED:REDACTED@mock/status/200",
)
def test_hooks(self): def test_hooks(self):
def request_hook(span, request_obj): def request_hook(span, request_obj):

View File

@ -274,7 +274,7 @@ from opentelemetry.util.http import (
get_custom_headers, get_custom_headers,
normalise_request_header_name, normalise_request_header_name,
normalise_response_header_name, normalise_response_header_name,
remove_url_credentials, redact_url,
sanitize_method, sanitize_method,
) )
@ -371,9 +371,7 @@ def collect_request_attributes(
else: else:
# old semconv v1.20.0 # old semconv v1.20.0
if _report_old(sem_conv_opt_in_mode): if _report_old(sem_conv_opt_in_mode):
result[HTTP_URL] = remove_url_credentials( result[HTTP_URL] = redact_url(wsgiref_util.request_uri(environ))
wsgiref_util.request_uri(environ)
)
remote_addr = environ.get("REMOTE_ADDR") remote_addr = environ.get("REMOTE_ADDR")
if remote_addr: if remote_addr:

View File

@ -818,11 +818,12 @@ class TestWsgiAttributes(unittest.TestCase):
self.assertEqual(mock_span.is_recording.call_count, 2) self.assertEqual(mock_span.is_recording.call_count, 2)
self.assertEqual(attrs[HTTP_STATUS_CODE], 404) self.assertEqual(attrs[HTTP_STATUS_CODE], 404)
def test_credential_removal(self): def test_remove_sensitive_params(self):
self.environ["HTTP_HOST"] = "username:password@mock" self.environ["HTTP_HOST"] = "username:password@mock"
self.environ["PATH_INFO"] = "/status/200" self.environ["PATH_INFO"] = "/status/200"
self.environ["QUERY_STRING"] = "sig=secret"
expected = { expected = {
HTTP_URL: "http://mock/status/200", HTTP_URL: "http://REDACTED:REDACTED@mock/status/200?sig=REDACTED",
NET_HOST_PORT: 80, NET_HOST_PORT: 80,
} }
self.assertGreaterEqual( self.assertGreaterEqual(

View File

@ -20,7 +20,7 @@ from re import IGNORECASE as RE_IGNORECASE
from re import compile as re_compile from re import compile as re_compile
from re import search from re import search
from typing import Callable, Iterable, overload from typing import Callable, Iterable, overload
from urllib.parse import urlparse, urlunparse from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from opentelemetry.semconv._incubating.attributes.http_attributes import ( from opentelemetry.semconv._incubating.attributes.http_attributes import (
HTTP_FLAVOR, HTTP_FLAVOR,
@ -69,6 +69,8 @@ _active_requests_count_attrs = {
HTTP_SERVER_NAME, HTTP_SERVER_NAME,
} }
PARAMS_TO_REDACT = ["AWSAccessKeyId", "Signature", "sig", "X-Goog-Signature"]
class ExcludeList: class ExcludeList:
"""Class to exclude certain paths (given as a list of regexes) from tracing requests""" """Class to exclude certain paths (given as a list of regexes) from tracing requests"""
@ -170,21 +172,21 @@ def parse_excluded_urls(excluded_urls: str) -> ExcludeList:
def remove_url_credentials(url: str) -> str: def remove_url_credentials(url: str) -> str:
"""Given a string url, remove the username and password only if it is a valid url""" """Given a string url, replace the username and password with the keyword `REDACTED` only if it is a valid url"""
try: try:
parsed = urlparse(url) parsed = urlparse(url)
if all([parsed.scheme, parsed.netloc]): # checks for valid url if all([parsed.scheme, parsed.netloc]): # checks for valid url
parsed_url = urlparse(url) if "@" in parsed.netloc:
_, _, netloc = parsed.netloc.rpartition("@") _, _, host = parsed.netloc.rpartition("@")
new_netloc = "REDACTED:REDACTED@" + host
return urlunparse( return urlunparse(
( (
parsed_url.scheme, parsed.scheme,
netloc, new_netloc,
parsed_url.path, parsed.path,
parsed_url.params, parsed.params,
parsed_url.query, parsed.query,
parsed_url.fragment, parsed.fragment,
) )
) )
except ValueError: # an unparsable url was passed except ValueError: # an unparsable url was passed
@ -266,3 +268,36 @@ def _parse_url_query(url: str):
path = parsed_url.path path = parsed_url.path
query_params = parsed_url.query query_params = parsed_url.query
return path, query_params return path, query_params
def redact_query_parameters(url: str) -> str:
"""Given a string url, redact sensitive query parameter values"""
try:
parsed = urlparse(url)
if not parsed.query: # No query parameters to redact
return url
query_params = parse_qs(parsed.query)
if not any(param in query_params for param in PARAMS_TO_REDACT):
return url
for param in PARAMS_TO_REDACT:
if param in query_params:
query_params[param] = ["REDACTED"]
return urlunparse(
(
parsed.scheme,
parsed.netloc,
parsed.path,
parsed.params,
urlencode(query_params, doseq=True),
parsed.fragment,
)
)
except ValueError: # an unparsable url was passed
return url
def redact_url(url: str) -> str:
"""Redact sensitive data from the URL, including credentials and query parameters."""
url = remove_url_credentials(url)
url = redact_query_parameters(url)
return url

View File

@ -0,0 +1,98 @@
# Copyright The OpenTelemetry Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import unittest
from opentelemetry.util.http import redact_query_parameters
class TestRedactSensitiveInfo(unittest.TestCase):
def test_redact_goog_signature(self):
url = "https://www.example.com/path?color=blue&X-Goog-Signature=secret"
self.assertEqual(
redact_query_parameters(url),
"https://www.example.com/path?color=blue&X-Goog-Signature=REDACTED",
)
def test_no_redaction_needed(self):
url = "https://www.example.com/path?color=blue&query=secret"
self.assertEqual(
redact_query_parameters(url),
"https://www.example.com/path?color=blue&query=secret",
)
def test_no_query_parameters(self):
url = "https://www.example.com/path"
self.assertEqual(
redact_query_parameters(url), "https://www.example.com/path"
)
def test_empty_query_string(self):
url = "https://www.example.com/path?"
self.assertEqual(
redact_query_parameters(url), "https://www.example.com/path?"
)
def test_empty_url(self):
url = ""
self.assertEqual(redact_query_parameters(url), "")
def test_redact_aws_access_key_id(self):
url = "https://www.example.com/path?color=blue&AWSAccessKeyId=secrets"
self.assertEqual(
redact_query_parameters(url),
"https://www.example.com/path?color=blue&AWSAccessKeyId=REDACTED",
)
def test_api_key_not_in_redact_list(self):
url = "https://www.example.com/path?api_key=secret%20key&user=john"
self.assertNotEqual(
redact_query_parameters(url),
"https://www.example.com/path?api_key=REDACTED&user=john",
)
def test_password_key_not_in_redact_list(self):
url = "https://api.example.com?key=abc&password=123&user=admin"
self.assertNotEqual(
redact_query_parameters(url),
"https://api.example.com?key=REDACTED&password=REDACTED&user=admin",
)
def test_url_with_at_symbol_in_path_and_query(self):
url = "https://example.com/p@th?foo=b@r"
self.assertEqual(
redact_query_parameters(url), "https://example.com/p@th?foo=b@r"
)
def test_aws_access_key_with_real_format(self):
url = "https://mock.com?AWSAccessKeyId=AKIAIOSFODNN7"
self.assertEqual(
redact_query_parameters(url),
"https://mock.com?AWSAccessKeyId=REDACTED",
)
def test_signature_parameter(self):
url = (
"https://service.com?sig=39Up9jzHkxhuIhFE9594DJxe7w6cIRCg0V6ICGS0"
)
self.assertEqual(
redact_query_parameters(url), "https://service.com?sig=REDACTED"
)
def test_signature_with_url_encoding(self):
url = "https://service.com?Signature=39Up9jzHkxhuIhFE9594DJxe7w6cIRCg0V6ICGS0%3A377"
self.assertEqual(
redact_query_parameters(url),
"https://service.com?Signature=REDACTED",
)

View File

@ -0,0 +1,64 @@
# Copyright The OpenTelemetry Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import unittest
from opentelemetry.util.http import redact_url
class TestRedactUrl(unittest.TestCase):
def test_redact_both_credentials_and_query_params(self):
"""Test URL with both credentials and sensitive query parameters."""
url = "https://user:password@api.example.com/data?AWSAccessKeyId=AKIAIOSFODNN7&color=blue"
expected = "https://REDACTED:REDACTED@api.example.com/data?AWSAccessKeyId=REDACTED&color=blue"
self.assertEqual(redact_url(url), expected)
def test_multiple_sensitive_query_params(self):
"""Test URL with multiple sensitive query parameters."""
url = "https://admin:1234@example.com/secure?Signature=abc123&X-Goog-Signature=xyz789&sig=def456"
expected = "https://REDACTED:REDACTED@example.com/secure?Signature=REDACTED&X-Goog-Signature=REDACTED&sig=REDACTED"
self.assertEqual(redact_url(url), expected)
def test_url_with_special_characters(self):
"""Test URL with special characters in both credentials and query parameters."""
url = "https://user@domain:p@ss!word@api.example.com/path?Signature=s%40me+special%20chars&normal=fine"
expected = "https://REDACTED:REDACTED@api.example.com/path?Signature=REDACTED&normal=fine"
self.assertEqual(redact_url(url), expected)
def test_edge_cases(self):
"""Test unusual URL formats and corner cases."""
# URL with fragment
url1 = (
"https://user:pass@api.example.com/data?Signature=secret#section"
)
self.assertEqual(
redact_url(url1),
"https://REDACTED:REDACTED@api.example.com/data?Signature=REDACTED#section",
)
# URL with port number
url2 = (
"https://user:pass@api.example.com:8443/data?AWSAccessKeyId=secret"
)
self.assertEqual(
redact_url(url2),
"https://REDACTED:REDACTED@api.example.com:8443/data?AWSAccessKeyId=REDACTED",
)
# URL with IP address instead of domain
url3 = "https://user:pass@192.168.1.1/path?X-Goog-Signature=xyz"
self.assertEqual(
redact_url(url3),
"https://REDACTED:REDACTED@192.168.1.1/path?X-Goog-Signature=REDACTED",
)

View File

@ -1,3 +1,17 @@
# Copyright The OpenTelemetry Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import unittest import unittest
from opentelemetry.util.http import remove_url_credentials from opentelemetry.util.http import remove_url_credentials
@ -5,27 +19,30 @@ from opentelemetry.util.http import remove_url_credentials
class TestRemoveUrlCredentials(unittest.TestCase): class TestRemoveUrlCredentials(unittest.TestCase):
def test_remove_no_credentials(self): def test_remove_no_credentials(self):
url = "http://opentelemetry.io:8080/test/path?query=value" url = "http://mock/status/200/test/path?query=value"
cleaned_url = remove_url_credentials(url) cleaned_url = remove_url_credentials(url)
self.assertEqual(cleaned_url, url) self.assertEqual(cleaned_url, url)
def test_remove_credentials(self): def test_remove_credentials(self):
url = "http://someuser:somepass@opentelemetry.io:8080/test/path?query=value" url = "http://someuser:somepass@mock/status/200/test/path?sig=value"
cleaned_url = remove_url_credentials(url) cleaned_url = remove_url_credentials(url)
self.assertEqual( self.assertEqual(
cleaned_url, "http://opentelemetry.io:8080/test/path?query=value" cleaned_url,
"http://REDACTED:REDACTED@mock/status/200/test/path?sig=value",
) )
def test_remove_credentials_ipv4_literal(self): def test_remove_credentials_ipv4_literal(self):
url = "http://someuser:somepass@127.0.0.1:8080/test/path?query=value" url = "http://someuser:somepass@127.0.0.1:8080/test/path?query=value"
cleaned_url = remove_url_credentials(url) cleaned_url = remove_url_credentials(url)
self.assertEqual( self.assertEqual(
cleaned_url, "http://127.0.0.1:8080/test/path?query=value" cleaned_url,
"http://REDACTED:REDACTED@127.0.0.1:8080/test/path?query=value",
) )
def test_remove_credentials_ipv6_literal(self): def test_remove_credentials_ipv6_literal(self):
url = "http://someuser:somepass@[::1]:8080/test/path?query=value" url = "http://someuser:somepass@[::1]:8080/test/path?query=value"
cleaned_url = remove_url_credentials(url) cleaned_url = remove_url_credentials(url)
self.assertEqual( self.assertEqual(
cleaned_url, "http://[::1]:8080/test/path?query=value" cleaned_url,
"http://REDACTED:REDACTED@[::1]:8080/test/path?query=value",
) )