Skip to content

Commit 7c926e7

Browse files
Šimon Šestákclaude
andcommitted
feat: Add query property to AnalyticEntry with privacy masking
Add GraphQL query string tracking to AnalyticEntry for analyzing query complexity and structure patterns. Implements secure-by-default literal masking to protect against accidental data leakage through inline values. Changes: - Add `query: String?` property to AnalyticEntry - Add `maskQueryLiterals: Bool` to AnalyticsConfiguration (default: true) - Implement query masking algorithm that: - Masks string literals ("admin" → "***") - Masks number literals (123 → ***) - Preserves variable references ($userId) - Preserves query structure for complexity analysis - Wire query parameter through FTNetworkTracer - Add 7 comprehensive tests for query masking functionality - Update CLAUDE.md documentation Privacy Levels: - .none/.private: Query included with optional literal masking - .sensitive: Query set to nil (most restrictive) Breaking Changes: None (purely additive) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 5a236ca commit 7c926e7

File tree

6 files changed

+370
-1
lines changed

6 files changed

+370
-1
lines changed

CLAUDE.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,18 @@ Located in `Sources/FTNetworkTracer/Analytics/`
7676
**AnalyticsConfiguration** - Privacy controls
7777
- Define sensitive query parameters, headers, and JSON body keys
7878
- URL masking automatically strips sensitive query parameters and masks path segments
79-
- Header/body/variables masking replaces sensitive values with `***MASKED***`
79+
- Header/body/variables masking replaces sensitive values with `***`
80+
- GraphQL query literal masking enabled by default (`maskQueryLiterals: true`)
81+
- Masks string literals (`"admin"``"***"`) and number literals (`123``***`)
82+
- Preserves query structure, field selections, and variable references (`$userId`)
83+
- Can be disabled with `maskQueryLiterals: false` for teams confident queries contain no sensitive data
8084

8185
**AnalyticEntry** - Masked data
8286
- All masking happens at initialization time based on configuration
8387
- Variables are deep-masked (handles nested dictionaries and arrays)
88+
- GraphQL queries include `query` property with literal masking applied
89+
- `.none`/`.private` privacy: Query included with optional literal masking
90+
- `.sensitive` privacy: Query set to `nil` (most restrictive)
8491

8592
### Data Flow
8693

@@ -105,6 +112,9 @@ Located in `Sources/FTNetworkTracer/Analytics/`
105112
- Logging: Privacy controlled via `OSLogPrivacy` levels
106113
- Analytics: Privacy via automatic masking in `AnalyticEntry` initializer
107114
- Masking is irreversible - once masked data is created, original data is gone
115+
- GraphQL query masking: Secure by default with `maskQueryLiterals: true`
116+
- Removes literal values from queries while preserving structure for complexity analysis
117+
- Consistent with variable masking behavior
108118

109119
## Platform Support
110120

Sources/FTNetworkTracer/Analytics/AnalyticEntry.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public struct AnalyticEntry: NetworkEntry {
1919
/// Additional context for GraphQL operations
2020
public let operationName: String?
2121
public let variables: [String: any Sendable]?
22+
public let query: String?
2223

2324
public init(
2425
type: EntryType,
@@ -29,6 +30,7 @@ public struct AnalyticEntry: NetworkEntry {
2930
requestId: String = UUID().uuidString,
3031
operationName: String? = nil,
3132
variables: [String: any Sendable]? = nil,
33+
query: String? = nil,
3234
configuration: AnalyticsConfiguration = AnalyticsConfiguration.default
3335
) {
3436
// Create masked type with masked URL
@@ -50,5 +52,6 @@ public struct AnalyticEntry: NetworkEntry {
5052
self.requestId = requestId
5153
self.operationName = operationName
5254
self.variables = configuration.maskVariables(variables)
55+
self.query = configuration.maskQuery(query)
5356
}
5457
}

Sources/FTNetworkTracer/Analytics/AnalyticsConfiguration.swift

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import Foundation
88
public struct AnalyticsConfiguration: Sendable {
99
/// The privacy level for data masking.
1010
public let privacy: AnalyticsPrivacy
11+
12+
/// Whether to mask literal values in GraphQL queries (default: true for security).
13+
public let maskQueryLiterals: Bool
14+
1115
private let unmaskedHeaders: Set<String>
1216
private let unmaskedUrlQueries: Set<String>
1317
private let unmaskedBodyParams: Set<String>
@@ -16,16 +20,19 @@ public struct AnalyticsConfiguration: Sendable {
1620
///
1721
/// - Parameters:
1822
/// - privacy: The privacy level for data masking.
23+
/// - maskQueryLiterals: Whether to mask literal values in GraphQL queries (default: true).
1924
/// - unmaskedHeaders: A set of header keys that should not be masked.
2025
/// - unmaskedUrlQueries: A set of URL query parameter keys that should not be masked.
2126
/// - unmaskedBodyParams: A set of body/variable parameter keys that should not be masked.
2227
public init(
2328
privacy: AnalyticsPrivacy,
29+
maskQueryLiterals: Bool = true,
2430
unmaskedHeaders: Set<String> = [],
2531
unmaskedUrlQueries: Set<String> = [],
2632
unmaskedBodyParams: Set<String> = []
2733
) {
2834
self.privacy = privacy
35+
self.maskQueryLiterals = maskQueryLiterals
2936
self.unmaskedHeaders = unmaskedHeaders
3037
self.unmaskedUrlQueries = unmaskedUrlQueries
3138
self.unmaskedBodyParams = unmaskedBodyParams
@@ -161,4 +168,128 @@ public struct AnalyticsConfiguration: Sendable {
161168
return "***"
162169
}
163170
}
171+
172+
func maskQuery(_ query: String?) -> String? {
173+
guard let query else {
174+
return nil
175+
}
176+
177+
switch privacy {
178+
case .none, .private:
179+
return maskQueryLiterals ? maskQueryLiteralValues(query) : query
180+
case .sensitive:
181+
return nil
182+
}
183+
}
184+
185+
private func maskQueryLiteralValues(_ query: String) -> String {
186+
var result = ""
187+
var insideString = false
188+
var insideParentheses = false
189+
var currentToken = ""
190+
var escapeNext = false
191+
192+
for char in query {
193+
// Handle escape sequences in strings
194+
if escapeNext {
195+
if insideString {
196+
currentToken.append(char)
197+
} else {
198+
result.append(char)
199+
}
200+
escapeNext = false
201+
continue
202+
}
203+
204+
if char == "\\" {
205+
escapeNext = true
206+
if insideString {
207+
currentToken.append(char)
208+
} else {
209+
result.append(char)
210+
}
211+
continue
212+
}
213+
214+
switch char {
215+
case "\"":
216+
if insideParentheses && !insideString {
217+
// Start of string literal in arguments
218+
insideString = true
219+
currentToken = "\""
220+
} else if insideString {
221+
// End of string literal - mask it
222+
insideString = false
223+
result.append("\"***\"")
224+
currentToken = ""
225+
} else {
226+
result.append(char)
227+
}
228+
229+
case "(":
230+
result.append(currentToken)
231+
result.append(char)
232+
currentToken = ""
233+
insideParentheses = true
234+
235+
case ")":
236+
// Flush any pending number literal
237+
if insideParentheses && !currentToken.isEmpty {
238+
if isNumericLiteral(currentToken) {
239+
result.append("***")
240+
} else {
241+
result.append(currentToken)
242+
}
243+
}
244+
result.append(char)
245+
currentToken = ""
246+
insideParentheses = false
247+
248+
case " ", "\n", "\t", ",", ":":
249+
if insideString {
250+
// Inside string literal - accumulate character
251+
currentToken.append(char)
252+
} else {
253+
// Delimiter - check if we have a pending number literal
254+
if insideParentheses && !currentToken.isEmpty {
255+
if isNumericLiteral(currentToken) {
256+
result.append("***")
257+
} else {
258+
result.append(currentToken)
259+
}
260+
currentToken = ""
261+
}
262+
result.append(char)
263+
}
264+
265+
default:
266+
if insideString {
267+
// Inside string literal - accumulate but don't output
268+
currentToken.append(char)
269+
} else if insideParentheses {
270+
// Might be building a number literal or variable reference
271+
currentToken.append(char)
272+
} else {
273+
// Outside arguments - pass through
274+
result.append(char)
275+
}
276+
}
277+
}
278+
279+
// Handle any remaining token
280+
if !currentToken.isEmpty {
281+
result.append(currentToken)
282+
}
283+
284+
return result
285+
}
286+
287+
private func isNumericLiteral(_ token: String) -> Bool {
288+
let trimmed = token.trimmingCharacters(in: .whitespaces)
289+
// Check if it's a number (int or float) but not a variable reference
290+
guard !trimmed.isEmpty && !trimmed.hasPrefix("$") else {
291+
return false
292+
}
293+
return Double(trimmed) != nil
294+
}
164295
}

Sources/FTNetworkTracer/FTNetworkTracer.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ public class FTNetworkTracer {
201201
requestId: requestId,
202202
operationName: operationName,
203203
variables: variables,
204+
query: query,
204205
configuration: analytics.configuration
205206
)
206207
analytics.track(analyticEntry)

0 commit comments

Comments
 (0)