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:
parent
6977da3893
commit
b69ebb7224
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = {}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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", "")
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,23 +172,23 @@ 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("@")
|
||||||
return urlunparse(
|
new_netloc = "REDACTED:REDACTED@" + host
|
||||||
(
|
return urlunparse(
|
||||||
parsed_url.scheme,
|
(
|
||||||
netloc,
|
parsed.scheme,
|
||||||
parsed_url.path,
|
new_netloc,
|
||||||
parsed_url.params,
|
parsed.path,
|
||||||
parsed_url.query,
|
parsed.params,
|
||||||
parsed_url.fragment,
|
parsed.query,
|
||||||
|
parsed.fragment,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
except ValueError: # an unparsable url was passed
|
except ValueError: # an unparsable url was passed
|
||||||
pass
|
pass
|
||||||
return url
|
return url
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
@ -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",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue