diff --git a/Sources/NautilusTelemetry/Tracing/Redaction.swift b/Sources/NautilusTelemetry/Tracing/Redaction.swift new file mode 100644 index 0000000..a952df2 --- /dev/null +++ b/Sources/NautilusTelemetry/Tracing/Redaction.swift @@ -0,0 +1,45 @@ +// Created by Ladd Van Tol on 12/15/25. +// Copyright © 2025 Airbnb Inc. All rights reserved. + +import Foundation + +public enum Redaction { + + // MARK: Public + + /// Provides a default implementation of URL redaction that hides common sensitive elements. + /// - Parameter url: A URL to redact + /// - Returns: A string representing the URL with sensitive data redacted. Returns nil if the URL cannot be decomposed. + static public func defaultUrlRedaction(_ url: URL) -> String? { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return nil } + + // https://opentelemetry.io/docs/specs/semconv/registry/attributes/url/#url-full + + if components.user != nil { + components.user = Redaction.redacted + } + + if components.password != nil { + components.password = Redaction.redacted + } + + if let queryItems = components.queryItems { + // Redact AWS security parameters by default + let prefixes: Set = ["x-amz-"] + components.queryItems = queryItems.map { queryItem in + let queryItemName = queryItem.name.lowercased() + if prefixes.contains(where: { queryItemName.hasPrefix($0) }) { + return URLQueryItem(name: queryItem.name, value: Redaction.redacted) + } else { + return queryItem + } + } + } + + return components.url?.absoluteString + } + + // MARK: Internal + + static public let redacted = "REDACTED" +} diff --git a/Sources/NautilusTelemetry/Tracing/Span+URLSession.swift b/Sources/NautilusTelemetry/Tracing/Span+URLSession.swift index 117a318..d2fef04 100644 --- a/Sources/NautilusTelemetry/Tracing/Span+URLSession.swift +++ b/Sources/NautilusTelemetry/Tracing/Span+URLSession.swift @@ -124,20 +124,16 @@ extension Span { /// - Parameters: /// - _: the URLSession instance. /// - task: the task. - /// - captureHeaders: a set of request headers to capture, or nil to capture none. - public func urlSession(_: URLSession, didCreateTask task: URLSessionTask, captureHeaders: Set? = nil) { + /// - captureHeaders: a set of request headers to capture, or nil to capture none. Must be lowercase strings. + /// - urlRedaction: A closure to map an URL into a String, redacting sensitive data. If not provided, a default implementation is used. + public func urlSession( + _: URLSession, + didCreateTask task: URLSessionTask, + captureHeaders: Set? = nil, + urlRedaction: ((URL) -> String?) = Redaction.defaultUrlRedaction + ) { if let request = task.currentRequest { - addAttribute("http.request.method", request.httpMethod ?? "_OTHER") - - if let url = request.url { - addAttribute("server.address", url.host) - addAttribute("server.port", url.port) - addAttribute("url.full", url.absoluteString) - addAttribute("url.scheme", url.scheme) - } - - addAttribute("user_agent.original", request.value(forHTTPHeaderField: "user-agent")) - addHeaders(request: request, captureHeaders: captureHeaders) + addRequestAttributes(request, captureHeaders: captureHeaders, urlRedaction: urlRedaction) } } @@ -147,7 +143,7 @@ extension Span { /// - task: the task. /// - error: an optional error. /// - recordAsStatusCodeFailure: whether to record as a failure due to status code when error == nil. - /// - captureHeaders: a set of response headers to capture, or nil to capture none. + /// - captureHeaders: a set of response headers to capture, or nil to capture none. Must be lowercase strings. public func urlSession( _: URLSession, task: URLSessionTask, @@ -199,7 +195,7 @@ extension Span { /// Adds specified headers from a HTTPURLResponse to this span /// - Parameters: /// - request: HTTPURLResponse containing headers - /// - captureHeaders: Headers to capture. Must be lowercase strings. + /// - captureHeaders: a set of response headers to capture, or nil to capture none. Must be lowercase strings. public func addHeaders(response: HTTPURLResponse, captureHeaders: Set? = nil) { guard let captureHeaders else { return } validate(captureHeaders: captureHeaders) @@ -290,6 +286,30 @@ extension Span { return Int64(duration * 1_000_000_000) } + /// Add URLRequest attributes to the span + /// - Parameters: + /// - request: request to fetch attributes from + /// - captureHeaders: a list of headers to capture. If nil, none will be captured + /// - urlRedaction: A closure to map an URL into a String, redacting sensitive data. If not provided, a default implementation is used. + func addRequestAttributes( + _ request: URLRequest, + captureHeaders: Set? = nil, + urlRedaction: ((URL) -> String?) = Redaction.defaultUrlRedaction + ) { + addAttribute("http.request.method", request.httpMethod ?? "_OTHER") + addAttribute("user_agent.original", request.value(forHTTPHeaderField: "user-agent")) + + if let url = request.url { + addAttribute("server.address", url.host) + addAttribute("server.port", url.port) + addAttribute("url.scheme", url.scheme) + + addAttribute("url.full", urlRedaction(url)) + } + + addHeaders(request: request, captureHeaders: captureHeaders) + } + // MARK: Private /// Derived from https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status diff --git a/Sources/NautilusTelemetry/Tracing/Tracer+URLRequest.swift b/Sources/NautilusTelemetry/Tracing/Tracer+URLRequest.swift index 0df67aa..d597b43 100644 --- a/Sources/NautilusTelemetry/Tracing/Tracer+URLRequest.swift +++ b/Sources/NautilusTelemetry/Tracing/Tracer+URLRequest.swift @@ -13,7 +13,7 @@ extension Tracer { /// - Parameters: /// - request: the URLRequest. The `traceparent` header will be added if needed. /// - template: optional [`url.template`](https://opentelemetry.io/docs/specs/semconv/registry/attributes/url/#url-template) value. - /// - captureHeaders: a set of request headers to capture, or nil to capture none. + /// - captureHeaders: a set of request headers to capture, or nil to capture none. Must be lowercase strings. /// - attributes: optional attributes. /// - baggage: Optional ``Baggage``, describing parent span. If nil, will be inferred from task/thread local baggage. /// - Returns: A newly created span. @@ -42,7 +42,7 @@ extension Tracer { /// - Parameters: /// - request: the URLRequest. The `traceparent` header will be added if needed. /// - template: optional [`url.template`](https://opentelemetry.io/docs/specs/semconv/registry/attributes/url/#url-template) value. - /// - captureHeaders: a set of request headers to capture, or nil to capture none. + /// - captureHeaders: a set of request headers to capture, or nil to capture none. Must be lowercase strings. /// - attributes: optional attributes. /// - baggage: Optional ``Baggage``, describing parent span. If nil, will be inferred from task/thread local baggage. /// - Returns: A newly created span. @@ -74,14 +74,8 @@ extension Tracer { template: String? = nil, isSampling: Bool ) { - span.addAttribute("http.request.method", request.httpMethod ?? "_OTHER") - span.addAttribute("user_agent.original", request.value(forHTTPHeaderField: "user-agent")) + span.addRequestAttributes(request) - if let url = request.url { - span.addAttribute("server.address", url.host) - span.addAttribute("server.port", url.port) - span.addAttribute("url.full", url.absoluteString) - } if let template { span.addAttribute("url.template", template) } diff --git a/Tests/NautilusTelemetryTests/Tracing/RedactionTests.swift b/Tests/NautilusTelemetryTests/Tracing/RedactionTests.swift new file mode 100644 index 0000000..1fd1e69 --- /dev/null +++ b/Tests/NautilusTelemetryTests/Tracing/RedactionTests.swift @@ -0,0 +1,37 @@ +// Created by Ladd Van Tol on 12/15/25. +// Copyright © 2025 Airbnb Inc. All rights reserved. + +import Foundation +import XCTest +@testable import NautilusTelemetry + +final class RedactionTests: XCTestCase { + + func testDefaultUrlRedactionRedactsUserAndPassword() throws { + let url = try XCTUnwrap(URL(string: "https://user:password@example.com/path")) + + let redacted = Redaction.defaultUrlRedaction(url) + + XCTAssertEqual(redacted, "https://REDACTED:REDACTED@example.com/path") + } + + func testDefaultUrlRedactionRedactsAmzQueryParams() throws { + let url = + try XCTUnwrap( + URL(string: "https://example.com/path?X-Amz-Security-Token=secret1&X-Amz-Signature=secret&other=value&X-Amz-Date=123") + ) + + let redacted = try XCTUnwrap(Redaction.defaultUrlRedaction(url)) + + XCTAssert(redacted.contains("X-Amz-Security-Token=REDACTED")) + XCTAssert(redacted.contains("X-Amz-Signature=REDACTED")) + XCTAssert(redacted.contains("other=value")) + XCTAssert(redacted.contains("X-Amz-Date=REDACTED")) + } + + func testDefaultUrlRedactionPreservesRegularQueryParams() throws { + let url = try XCTUnwrap(URL(string: "https://example.com/path?foo=bar&baz=qux")) + let redacted = Redaction.defaultUrlRedaction(url) + XCTAssertEqual(redacted, "https://example.com/path?foo=bar&baz=qux") + } +} diff --git a/Tests/NautilusTelemetryTests/Tracing/Span+URLSessionTests.swift b/Tests/NautilusTelemetryTests/Tracing/Span+URLSessionTests.swift index 2a4f5b7..b58ec60 100644 --- a/Tests/NautilusTelemetryTests/Tracing/Span+URLSessionTests.swift +++ b/Tests/NautilusTelemetryTests/Tracing/Span+URLSessionTests.swift @@ -124,4 +124,54 @@ final class SpanURLSessionTests: XCTestCase { XCTAssertEqual(attributes["duration_zero"], 0) XCTAssertEqual(attributes["duration_one_second"], 1_000_000_000) } + + func testUrlSchemeAttributeCaptured() throws { + let span = tracer.startSpan(name: #function) + let url = try makeURL("/test") + let urlRequest = URLRequest(url: url) + + span.addRequestAttributes(urlRequest) + + let attributes = try XCTUnwrap(span.attributes) + XCTAssertEqual(attributes["url.scheme"], url.scheme) + } + + func testCustomUrlRedaction() throws { + let span = tracer.startSpan(name: #function) + let url = try makeURL("/sensitive/path?key=secret") + let urlRequest = URLRequest(url: url) + + let customRedaction: (URL) -> String? = { _ in "https://redacted.example.com" } + + span.addRequestAttributes(urlRequest, urlRedaction: customRedaction) + + let attributes = try XCTUnwrap(span.attributes) + XCTAssertEqual(attributes["url.full"], "https://redacted.example.com") + } + + func testUrlSessionDidCreateTaskWithCustomRedaction() throws { + let span = tracer.startSpan(name: #function) + let url = try XCTUnwrap(URL(string: "https://user:pass@example.com/path")) + let urlRequest = URLRequest(url: url) + let task = urlSession.dataTask(with: urlRequest) + + let customRedaction: (URL) -> String? = { _ in "https://custom.redacted.com" } + + span.urlSession(urlSession, didCreateTask: task, urlRedaction: customRedaction) + + let attributes = try XCTUnwrap(span.attributes) + XCTAssertEqual(attributes["url.full"], "https://custom.redacted.com") + } + + func testUrlSessionDidCreateTaskUsesDefaultRedaction() throws { + let span = tracer.startSpan(name: #function) + let url = try XCTUnwrap(URL(string: "https://user:password@example.com/path")) + let urlRequest = URLRequest(url: url) + let task = urlSession.dataTask(with: urlRequest) + + span.urlSession(urlSession, didCreateTask: task) + + let attributes = try XCTUnwrap(span.attributes as? [String: String]) + XCTAssertEqual(attributes["url.full"], "https://REDACTED:REDACTED@example.com/path") + } }