Skip to content

Commit 3147746

Browse files
authored
Implement URL redaction (#52)
* Implement URL redaction Fix comment Prefix * Make defaultUrlRedaction public * Public constant
1 parent 6ce0f6f commit 3147746

File tree

5 files changed

+170
-24
lines changed

5 files changed

+170
-24
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Created by Ladd Van Tol on 12/15/25.
2+
// Copyright © 2025 Airbnb Inc. All rights reserved.
3+
4+
import Foundation
5+
6+
public enum Redaction {
7+
8+
// MARK: Public
9+
10+
/// Provides a default implementation of URL redaction that hides common sensitive elements.
11+
/// - Parameter url: A URL to redact
12+
/// - Returns: A string representing the URL with sensitive data redacted. Returns nil if the URL cannot be decomposed.
13+
static public func defaultUrlRedaction(_ url: URL) -> String? {
14+
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return nil }
15+
16+
// https://opentelemetry.io/docs/specs/semconv/registry/attributes/url/#url-full
17+
18+
if components.user != nil {
19+
components.user = Redaction.redacted
20+
}
21+
22+
if components.password != nil {
23+
components.password = Redaction.redacted
24+
}
25+
26+
if let queryItems = components.queryItems {
27+
// Redact AWS security parameters by default
28+
let prefixes: Set<String> = ["x-amz-"]
29+
components.queryItems = queryItems.map { queryItem in
30+
let queryItemName = queryItem.name.lowercased()
31+
if prefixes.contains(where: { queryItemName.hasPrefix($0) }) {
32+
return URLQueryItem(name: queryItem.name, value: Redaction.redacted)
33+
} else {
34+
return queryItem
35+
}
36+
}
37+
}
38+
39+
return components.url?.absoluteString
40+
}
41+
42+
// MARK: Internal
43+
44+
static public let redacted = "REDACTED"
45+
}

Sources/NautilusTelemetry/Tracing/Span+URLSession.swift

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -124,20 +124,16 @@ extension Span {
124124
/// - Parameters:
125125
/// - _: the URLSession instance.
126126
/// - task: the task.
127-
/// - captureHeaders: a set of request headers to capture, or nil to capture none.
128-
public func urlSession(_: URLSession, didCreateTask task: URLSessionTask, captureHeaders: Set<String>? = nil) {
127+
/// - captureHeaders: a set of request headers to capture, or nil to capture none. Must be lowercase strings.
128+
/// - urlRedaction: A closure to map an URL into a String, redacting sensitive data. If not provided, a default implementation is used.
129+
public func urlSession(
130+
_: URLSession,
131+
didCreateTask task: URLSessionTask,
132+
captureHeaders: Set<String>? = nil,
133+
urlRedaction: ((URL) -> String?) = Redaction.defaultUrlRedaction
134+
) {
129135
if let request = task.currentRequest {
130-
addAttribute("http.request.method", request.httpMethod ?? "_OTHER")
131-
132-
if let url = request.url {
133-
addAttribute("server.address", url.host)
134-
addAttribute("server.port", url.port)
135-
addAttribute("url.full", url.absoluteString)
136-
addAttribute("url.scheme", url.scheme)
137-
}
138-
139-
addAttribute("user_agent.original", request.value(forHTTPHeaderField: "user-agent"))
140-
addHeaders(request: request, captureHeaders: captureHeaders)
136+
addRequestAttributes(request, captureHeaders: captureHeaders, urlRedaction: urlRedaction)
141137
}
142138
}
143139

@@ -147,7 +143,7 @@ extension Span {
147143
/// - task: the task.
148144
/// - error: an optional error.
149145
/// - recordAsStatusCodeFailure: whether to record as a failure due to status code when error == nil.
150-
/// - captureHeaders: a set of response headers to capture, or nil to capture none.
146+
/// - captureHeaders: a set of response headers to capture, or nil to capture none. Must be lowercase strings.
151147
public func urlSession(
152148
_: URLSession,
153149
task: URLSessionTask,
@@ -199,7 +195,7 @@ extension Span {
199195
/// Adds specified headers from a HTTPURLResponse to this span
200196
/// - Parameters:
201197
/// - request: HTTPURLResponse containing headers
202-
/// - captureHeaders: Headers to capture. Must be lowercase strings.
198+
/// - captureHeaders: a set of response headers to capture, or nil to capture none. Must be lowercase strings.
203199
public func addHeaders(response: HTTPURLResponse, captureHeaders: Set<String>? = nil) {
204200
guard let captureHeaders else { return }
205201
validate(captureHeaders: captureHeaders)
@@ -290,6 +286,30 @@ extension Span {
290286
return Int64(duration * 1_000_000_000)
291287
}
292288

289+
/// Add URLRequest attributes to the span
290+
/// - Parameters:
291+
/// - request: request to fetch attributes from
292+
/// - captureHeaders: a list of headers to capture. If nil, none will be captured
293+
/// - urlRedaction: A closure to map an URL into a String, redacting sensitive data. If not provided, a default implementation is used.
294+
func addRequestAttributes(
295+
_ request: URLRequest,
296+
captureHeaders: Set<String>? = nil,
297+
urlRedaction: ((URL) -> String?) = Redaction.defaultUrlRedaction
298+
) {
299+
addAttribute("http.request.method", request.httpMethod ?? "_OTHER")
300+
addAttribute("user_agent.original", request.value(forHTTPHeaderField: "user-agent"))
301+
302+
if let url = request.url {
303+
addAttribute("server.address", url.host)
304+
addAttribute("server.port", url.port)
305+
addAttribute("url.scheme", url.scheme)
306+
307+
addAttribute("url.full", urlRedaction(url))
308+
}
309+
310+
addHeaders(request: request, captureHeaders: captureHeaders)
311+
}
312+
293313
// MARK: Private
294314

295315
/// Derived from https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status

Sources/NautilusTelemetry/Tracing/Tracer+URLRequest.swift

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ extension Tracer {
1313
/// - Parameters:
1414
/// - request: the URLRequest. The `traceparent` header will be added if needed.
1515
/// - template: optional [`url.template`](https://opentelemetry.io/docs/specs/semconv/registry/attributes/url/#url-template) value.
16-
/// - captureHeaders: a set of request headers to capture, or nil to capture none.
16+
/// - captureHeaders: a set of request headers to capture, or nil to capture none. Must be lowercase strings.
1717
/// - attributes: optional attributes.
1818
/// - baggage: Optional ``Baggage``, describing parent span. If nil, will be inferred from task/thread local baggage.
1919
/// - Returns: A newly created span.
@@ -42,7 +42,7 @@ extension Tracer {
4242
/// - Parameters:
4343
/// - request: the URLRequest. The `traceparent` header will be added if needed.
4444
/// - template: optional [`url.template`](https://opentelemetry.io/docs/specs/semconv/registry/attributes/url/#url-template) value.
45-
/// - captureHeaders: a set of request headers to capture, or nil to capture none.
45+
/// - captureHeaders: a set of request headers to capture, or nil to capture none. Must be lowercase strings.
4646
/// - attributes: optional attributes.
4747
/// - baggage: Optional ``Baggage``, describing parent span. If nil, will be inferred from task/thread local baggage.
4848
/// - Returns: A newly created span.
@@ -74,14 +74,8 @@ extension Tracer {
7474
template: String? = nil,
7575
isSampling: Bool
7676
) {
77-
span.addAttribute("http.request.method", request.httpMethod ?? "_OTHER")
78-
span.addAttribute("user_agent.original", request.value(forHTTPHeaderField: "user-agent"))
77+
span.addRequestAttributes(request)
7978

80-
if let url = request.url {
81-
span.addAttribute("server.address", url.host)
82-
span.addAttribute("server.port", url.port)
83-
span.addAttribute("url.full", url.absoluteString)
84-
}
8579
if let template {
8680
span.addAttribute("url.template", template)
8781
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Created by Ladd Van Tol on 12/15/25.
2+
// Copyright © 2025 Airbnb Inc. All rights reserved.
3+
4+
import Foundation
5+
import XCTest
6+
@testable import NautilusTelemetry
7+
8+
final class RedactionTests: XCTestCase {
9+
10+
func testDefaultUrlRedactionRedactsUserAndPassword() throws {
11+
let url = try XCTUnwrap(URL(string: "https://user:password@example.com/path"))
12+
13+
let redacted = Redaction.defaultUrlRedaction(url)
14+
15+
XCTAssertEqual(redacted, "https://REDACTED:REDACTED@example.com/path")
16+
}
17+
18+
func testDefaultUrlRedactionRedactsAmzQueryParams() throws {
19+
let url =
20+
try XCTUnwrap(
21+
URL(string: "https://example.com/path?X-Amz-Security-Token=secret1&X-Amz-Signature=secret&other=value&X-Amz-Date=123")
22+
)
23+
24+
let redacted = try XCTUnwrap(Redaction.defaultUrlRedaction(url))
25+
26+
XCTAssert(redacted.contains("X-Amz-Security-Token=REDACTED"))
27+
XCTAssert(redacted.contains("X-Amz-Signature=REDACTED"))
28+
XCTAssert(redacted.contains("other=value"))
29+
XCTAssert(redacted.contains("X-Amz-Date=REDACTED"))
30+
}
31+
32+
func testDefaultUrlRedactionPreservesRegularQueryParams() throws {
33+
let url = try XCTUnwrap(URL(string: "https://example.com/path?foo=bar&baz=qux"))
34+
let redacted = Redaction.defaultUrlRedaction(url)
35+
XCTAssertEqual(redacted, "https://example.com/path?foo=bar&baz=qux")
36+
}
37+
}

Tests/NautilusTelemetryTests/Tracing/Span+URLSessionTests.swift

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,54 @@ final class SpanURLSessionTests: XCTestCase {
124124
XCTAssertEqual(attributes["duration_zero"], 0)
125125
XCTAssertEqual(attributes["duration_one_second"], 1_000_000_000)
126126
}
127+
128+
func testUrlSchemeAttributeCaptured() throws {
129+
let span = tracer.startSpan(name: #function)
130+
let url = try makeURL("/test")
131+
let urlRequest = URLRequest(url: url)
132+
133+
span.addRequestAttributes(urlRequest)
134+
135+
let attributes = try XCTUnwrap(span.attributes)
136+
XCTAssertEqual(attributes["url.scheme"], url.scheme)
137+
}
138+
139+
func testCustomUrlRedaction() throws {
140+
let span = tracer.startSpan(name: #function)
141+
let url = try makeURL("/sensitive/path?key=secret")
142+
let urlRequest = URLRequest(url: url)
143+
144+
let customRedaction: (URL) -> String? = { _ in "https://redacted.example.com" }
145+
146+
span.addRequestAttributes(urlRequest, urlRedaction: customRedaction)
147+
148+
let attributes = try XCTUnwrap(span.attributes)
149+
XCTAssertEqual(attributes["url.full"], "https://redacted.example.com")
150+
}
151+
152+
func testUrlSessionDidCreateTaskWithCustomRedaction() throws {
153+
let span = tracer.startSpan(name: #function)
154+
let url = try XCTUnwrap(URL(string: "https://user:pass@example.com/path"))
155+
let urlRequest = URLRequest(url: url)
156+
let task = urlSession.dataTask(with: urlRequest)
157+
158+
let customRedaction: (URL) -> String? = { _ in "https://custom.redacted.com" }
159+
160+
span.urlSession(urlSession, didCreateTask: task, urlRedaction: customRedaction)
161+
162+
let attributes = try XCTUnwrap(span.attributes)
163+
XCTAssertEqual(attributes["url.full"], "https://custom.redacted.com")
164+
}
165+
166+
func testUrlSessionDidCreateTaskUsesDefaultRedaction() throws {
167+
let span = tracer.startSpan(name: #function)
168+
let url = try XCTUnwrap(URL(string: "https://user:password@example.com/path"))
169+
let urlRequest = URLRequest(url: url)
170+
let task = urlSession.dataTask(with: urlRequest)
171+
172+
span.urlSession(urlSession, didCreateTask: task)
173+
174+
let attributes = try XCTUnwrap(span.attributes as? [String: String])
175+
XCTAssertEqual(attributes["url.full"], "https://REDACTED:REDACTED@example.com/path")
176+
}
127177
}

0 commit comments

Comments
 (0)