diff --git a/CLAUDE.md b/CLAUDE.md index 761ab5f..9869305 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,11 +76,18 @@ Located in `Sources/FTNetworkTracer/Analytics/` **AnalyticsConfiguration** - Privacy controls - Define sensitive query parameters, headers, and JSON body keys - URL masking automatically strips sensitive query parameters and masks path segments -- Header/body/variables masking replaces sensitive values with `***MASKED***` +- Header/body/variables masking replaces sensitive values with `***` +- GraphQL query literal masking enabled by default (`maskQueryLiterals: true`) + - Masks string literals (`"admin"` โ†’ `"***"`) and number literals (`123` โ†’ `***`) + - Preserves query structure, field selections, and variable references (`$userId`) + - Can be disabled with `maskQueryLiterals: false` for teams confident queries contain no sensitive data **AnalyticEntry** - Masked data - All masking happens at initialization time based on configuration - Variables are deep-masked (handles nested dictionaries and arrays) +- GraphQL queries include `query` property with literal masking applied + - `.none`/`.private` privacy: Query included with optional literal masking + - `.sensitive` privacy: Query set to `nil` (most restrictive) ### Data Flow @@ -105,6 +112,9 @@ Located in `Sources/FTNetworkTracer/Analytics/` - Logging: Privacy controlled via `OSLogPrivacy` levels - Analytics: Privacy via automatic masking in `AnalyticEntry` initializer - Masking is irreversible - once masked data is created, original data is gone +- GraphQL query masking: Secure by default with `maskQueryLiterals: true` + - Removes literal values from queries while preserving structure for complexity analysis + - Consistent with variable masking behavior ## Platform Support diff --git a/README.md b/README.md index 1b8d302..8684f82 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,13 @@ A Swift package for comprehensive network request logging and analytics tracking - ๐Ÿ” **Dual-mode operation**: Simultaneous logging and analytics tracking - ๐Ÿ”’ **Privacy-first design**: Configurable data masking with three privacy levels - ๐ŸŒ **REST & GraphQL support**: Specialized formatting for both API types +- ๐Ÿ” **GraphQL query masking**: Automatic literal masking for privacy-safe analytics - ๐Ÿ“Š **Structured logging**: Uses `os.log` for performance and privacy - ๐ŸŽš๏ธ **Log level filtering**: Configurable minimum threshold (debug, info, error, fault) - ๐Ÿง **Linux support**: Full cross-platform compatibility with CI/CD - ๐ŸŽฏ **Type-safe**: Associated values eliminate impossible states - โšก **Zero dependencies**: Pure Swift implementation -- ๐Ÿงช **Fully tested**: 65+ tests including comprehensive security tests +- ๐Ÿงช **Fully tested**: 75 tests including comprehensive security and privacy tests - ๐Ÿ”„ **Swift 6 ready**: Strict concurrency compliant with `Sendable` support ## Requirements @@ -53,6 +54,17 @@ class MyAnalytics: AnalyticsProtocol { func track(_ entry: AnalyticEntry) { // Send to your analytics service print("Tracking: \(entry.method) \(entry.url)") + + // Access GraphQL query for complexity analysis + if let query = entry.query { + analyzeQueryComplexity(query) + } + } + + func analyzeQueryComplexity(_ query: String) { + // Analyze query structure without seeing sensitive literals + let fieldCount = query.components(separatedBy: "\n").count + print("Query complexity: \(fieldCount) lines") } } @@ -173,17 +185,25 @@ let config = AnalyticsConfiguration( unmaskedBodyParams: ["username", "email"] ) +// Private mode with GraphQL query literal masking disabled +let config = AnalyticsConfiguration( + privacy: .private, + maskQueryLiterals: false // Disable query literal masking (default: true) +) + // No privacy (development only) let config = AnalyticsConfiguration(privacy: .none) ``` ### Privacy Levels -| Level | Headers | URL Queries | Body | Use Case | -|-------|---------|-------------|------|----------| -| **`.none`** | โœ… Preserved | โœ… Preserved | โœ… Preserved | Development only | -| **`.private`** | โš ๏ธ Masked (with exceptions) | โš ๏ธ Masked (with exceptions) | โš ๏ธ Masked (with exceptions) | Production with selective tracking | -| **`.sensitive`** | ๐Ÿ”’ All masked | ๐Ÿ”’ All removed | ๐Ÿ”’ Removed | Production with maximum privacy | +| Level | Headers | URL Queries | Body | GraphQL Queries | Use Case | +|-------|---------|-------------|------|----------------|----------| +| **`.none`** | โœ… Preserved | โœ… Preserved | โœ… Preserved | โš ๏ธ Literals masked* | Development only | +| **`.private`** | โš ๏ธ Masked (with exceptions) | โš ๏ธ Masked (with exceptions) | โš ๏ธ Masked (with exceptions) | โš ๏ธ Literals masked* | Production with selective tracking | +| **`.sensitive`** | ๐Ÿ”’ All masked | ๐Ÿ”’ All removed | ๐Ÿ”’ Removed | ๐Ÿ”’ Removed (nil) | Production with maximum privacy | + +\* GraphQL query literal masking is **enabled by default** (`maskQueryLiterals: true`) to prevent accidental data leakage. Can be disabled if needed. ## Privacy & Security @@ -195,6 +215,11 @@ FTNetworkTracer automatically masks sensitive data in analytics: - **URL Parameters**: All query parameters (in `.sensitive` mode) - **Body Fields**: `password`, `token`, `secret`, `creditCard`, `ssn`, etc. - **GraphQL Variables**: All variables unless explicitly unmasked +- **GraphQL Query Literals**: String and number literals in queries (enabled by default) + - `"admin"` โ†’ `"***"` + - `123` โ†’ `***` + - Variable references like `$userId` are preserved + - Query structure is preserved for complexity analysis ### Masking is Irreversible @@ -258,6 +283,35 @@ Variables: } ``` +### GraphQL Query Masking (Analytics) + +When tracking analytics, GraphQL queries are automatically masked for privacy: + +**Original Query:** +```graphql +query GetUser($userId: ID!) { + user(id: $userId, role: "admin", minAge: 18) { + name + email + } +} +``` + +**Masked Query in AnalyticEntry (default behavior):** +```graphql +query GetUser($userId: ID!) { + user(id: $userId, role: "***", minAge: ***) { + name + email + } +} +``` + +โœ… **Preserved**: Query structure, field selections, variable references (`$userId`), boolean literals (`true`, `false`), null literals (`null`), enum values (e.g., `ADMIN`, `ACTIVE`) +๐Ÿ”’ **Masked**: String literals (`"admin"`), number literals (`18`) + +This allows you to analyze query complexity and patterns without exposing sensitive data. + ## Architecture FTNetworkTracer uses a **dual-mode architecture**: @@ -292,12 +346,14 @@ FTNetworkTracer uses a **dual-mode architecture**: ## Test Coverage -- **AnalyticsTests** (4 tests): Privacy masking for all levels +- **AnalyticsTests** (11 tests): Privacy masking for all levels + GraphQL query masking - **GraphQLFormatterTests** (11 tests): Query and variable formatting -- **IntegrationTests** (15 tests): End-to-end flows -- **LoggingTests** (4 tests): Log message building +- **IntegrationTests** (16 tests): End-to-end flows including query analytics +- **LoggingTests** (6 tests): Log message building and level filtering - **RESTFormatterTests** (9 tests): Body formatting -- **SecurityTests** (22 tests): Comprehensive security validation +- **SecurityTests** (27 tests): Comprehensive security validation (including 5 new GraphQL query masking tests) + +**Total: 80 tests** with full coverage of privacy, security, and GraphQL query masking ## Example Projects diff --git a/Sources/FTNetworkTracer/Analytics/AnalyticEntry.swift b/Sources/FTNetworkTracer/Analytics/AnalyticEntry.swift index 7826b20..7520982 100644 --- a/Sources/FTNetworkTracer/Analytics/AnalyticEntry.swift +++ b/Sources/FTNetworkTracer/Analytics/AnalyticEntry.swift @@ -19,6 +19,7 @@ public struct AnalyticEntry: NetworkEntry { /// Additional context for GraphQL operations public let operationName: String? public let variables: [String: any Sendable]? + public let query: String? public init( type: EntryType, @@ -29,6 +30,7 @@ public struct AnalyticEntry: NetworkEntry { requestId: String = UUID().uuidString, operationName: String? = nil, variables: [String: any Sendable]? = nil, + query: String? = nil, configuration: AnalyticsConfiguration = AnalyticsConfiguration.default ) { // Create masked type with masked URL @@ -50,5 +52,6 @@ public struct AnalyticEntry: NetworkEntry { self.requestId = requestId self.operationName = operationName self.variables = configuration.maskVariables(variables) + self.query = configuration.maskQuery(query) } } diff --git a/Sources/FTNetworkTracer/Analytics/AnalyticsConfiguration.swift b/Sources/FTNetworkTracer/Analytics/AnalyticsConfiguration.swift index 3f37184..ae8cf38 100644 --- a/Sources/FTNetworkTracer/Analytics/AnalyticsConfiguration.swift +++ b/Sources/FTNetworkTracer/Analytics/AnalyticsConfiguration.swift @@ -8,6 +8,10 @@ import Foundation public struct AnalyticsConfiguration: Sendable { /// The privacy level for data masking. public let privacy: AnalyticsPrivacy + + /// Whether to mask literal values in GraphQL queries (default: true for security). + public let maskQueryLiterals: Bool + private let unmaskedHeaders: Set private let unmaskedUrlQueries: Set private let unmaskedBodyParams: Set @@ -16,16 +20,19 @@ public struct AnalyticsConfiguration: Sendable { /// /// - Parameters: /// - privacy: The privacy level for data masking. + /// - maskQueryLiterals: Whether to mask literal values in GraphQL queries (default: true). /// - unmaskedHeaders: A set of header keys that should not be masked. /// - unmaskedUrlQueries: A set of URL query parameter keys that should not be masked. /// - unmaskedBodyParams: A set of body/variable parameter keys that should not be masked. public init( privacy: AnalyticsPrivacy, + maskQueryLiterals: Bool = true, unmaskedHeaders: Set = [], unmaskedUrlQueries: Set = [], unmaskedBodyParams: Set = [] ) { self.privacy = privacy + self.maskQueryLiterals = maskQueryLiterals self.unmaskedHeaders = unmaskedHeaders self.unmaskedUrlQueries = unmaskedUrlQueries self.unmaskedBodyParams = unmaskedBodyParams @@ -161,4 +168,17 @@ public struct AnalyticsConfiguration: Sendable { return "***" } } + + func maskQuery(_ query: String?) -> String? { + guard let query else { + return nil + } + + switch privacy { + case .none, .private: + return maskQueryLiterals ? QueryLiteralMasker(query: query).maskedQuery : query + case .sensitive: + return nil + } + } } diff --git a/Sources/FTNetworkTracer/Analytics/QueryLiteralMasker.swift b/Sources/FTNetworkTracer/Analytics/QueryLiteralMasker.swift new file mode 100644 index 0000000..5eb26b8 --- /dev/null +++ b/Sources/FTNetworkTracer/Analytics/QueryLiteralMasker.swift @@ -0,0 +1,164 @@ +import Foundation + +/// Masks literal values in GraphQL queries while preserving structure +struct QueryLiteralMasker { + /// The masked query with literals replaced by "***" + let maskedQuery: String + + /// Initializes a masker and processes the query + /// - Parameter query: The GraphQL query string to mask + init(query: String) { + var processor = Processor() + self.maskedQuery = processor.process(query) + } + + // MARK: - Internal Processor + + private struct Processor { + private var result = "" + private var insideString = false + private var insideParentheses = false + private var currentToken = "" + private var escapeNext = false + + mutating func process(_ query: String) -> String { + for char in query { + if escapeNext { + handleEscapedCharacter(char) + continue + } + + if char == "\\" { + handleBackslash(char) + continue + } + + processCharacter(char) + } + + finalize() + return result + } + + // MARK: - Character Handlers + + private mutating func handleEscapedCharacter(_ char: Character) { + if insideString { + currentToken.append(char) + } else { + result.append(char) + } + escapeNext = false + } + + private mutating func handleBackslash(_ char: Character) { + escapeNext = true + if insideString { + currentToken.append(char) + } else { + result.append(char) + } + } + + private mutating func processCharacter(_ char: Character) { + switch char { + case "\"": + handleQuote() + case "(": + handleOpenParenthesis(char) + case ")": + handleCloseParenthesis(char) + case " ", "\n", "\t", ",", ":": + handleDelimiter(char) + default: + handleDefaultCharacter(char) + } + } + + private mutating func handleQuote() { + if insideParentheses && !insideString { + insideString = true + currentToken = "\"" + } else if insideString { + insideString = false + result.append("\"***\"") + currentToken = "" + } else { + result.append("\"") + } + } + + private mutating func handleOpenParenthesis(_ char: Character) { + if insideString { + // Inside string literal - treat as regular character + currentToken.append(char) + } else { + result.append(currentToken) + result.append(char) + currentToken = "" + insideParentheses = true + } + } + + private mutating func handleCloseParenthesis(_ char: Character) { + if insideString { + // Inside string literal - treat as regular character + currentToken.append(char) + } else { + flushToken() + result.append(char) + currentToken = "" + insideParentheses = false + } + } + + private mutating func handleDelimiter(_ char: Character) { + if insideString { + currentToken.append(char) + } else { + flushToken() + result.append(char) + } + } + + private mutating func handleDefaultCharacter(_ char: Character) { + if insideString || insideParentheses { + currentToken.append(char) + } else { + result.append(char) + } + } + + private mutating func flushToken() { + guard insideParentheses && !currentToken.isEmpty else { + return + } + + if isNumericLiteral(currentToken) { + result.append("***") + } else { + result.append(currentToken) + } + currentToken = "" + } + + private mutating func finalize() { + // Handle any remaining token + if insideString { + // Unclosed string - mask it for safety + result.append("\"***\"") + } else if !currentToken.isEmpty { + result.append(currentToken) + } + } + + private func isNumericLiteral(_ token: String) -> Bool { + let trimmed = token.trimmingCharacters(in: .whitespaces) + // Check if it's a number (int or float) but not a variable reference + guard !trimmed.isEmpty && !trimmed.hasPrefix("$") else { + return false + } + return Double(trimmed) != nil + } + } +} diff --git a/Sources/FTNetworkTracer/FTNetworkTracer.swift b/Sources/FTNetworkTracer/FTNetworkTracer.swift index ed08fc8..2fd98c9 100644 --- a/Sources/FTNetworkTracer/FTNetworkTracer.swift +++ b/Sources/FTNetworkTracer/FTNetworkTracer.swift @@ -201,6 +201,7 @@ public class FTNetworkTracer { requestId: requestId, operationName: operationName, variables: variables, + query: query, configuration: analytics.configuration ) analytics.track(analyticEntry) diff --git a/Tests/FTNetworkTracerTests/AnalyticsTests.swift b/Tests/FTNetworkTracerTests/AnalyticsTests.swift index df262ad..5c43f8c 100644 --- a/Tests/FTNetworkTracerTests/AnalyticsTests.swift +++ b/Tests/FTNetworkTracerTests/AnalyticsTests.swift @@ -119,4 +119,190 @@ class AnalyticsTests: XCTestCase { let secondObjectInArray = array[1] as! [String: Any] XCTAssertEqual(secondObjectInArray["public_param"] as? String, "visible") } + + // MARK: - Query Masking Tests + + func testQueryLiteralMaskingEnabledByDefault() { + let config = AnalyticsConfiguration(privacy: .private) + let query = """ + query GetUser { + user(id: "12345", age: 25, role: "admin") { + name + email + } + } + """ + + let entry = AnalyticEntry( + type: .request(method: "POST", url: "https://api.example.com/graphql"), + operationName: "GetUser", + query: query, + configuration: config + ) + + // Literals should be masked by default + XCTAssertNotNil(entry.query) + XCTAssertFalse(entry.query?.contains("12345") ?? true) + XCTAssertFalse(entry.query?.contains("\"admin\"") ?? true) + XCTAssertTrue(entry.query?.contains("id: \"***\"") ?? false) + XCTAssertTrue(entry.query?.contains("age: ***") ?? false) + XCTAssertTrue(entry.query?.contains("user") ?? false) // Structure preserved + XCTAssertTrue(entry.query?.contains("name") ?? false) + XCTAssertTrue(entry.query?.contains("email") ?? false) + } + + func testQueryIncludedWithoutMasking() { + let config = AnalyticsConfiguration(privacy: .private, maskQueryLiterals: false) + let query = """ + query GetUser($id: ID!) { + user(id: $id, role: "admin") { + name + email + } + } + """ + + let entry = AnalyticEntry( + type: .request(method: "POST", url: "https://api.example.com/graphql"), + operationName: "GetUser", + query: query, + configuration: config + ) + + // Query should be included as-is + XCTAssertEqual(entry.query, query) + XCTAssertTrue(entry.query?.contains("$id") ?? false) + XCTAssertTrue(entry.query?.contains("\"admin\"") ?? false) + } + + func testQueryPreservesVariableReferences() { + let config = AnalyticsConfiguration(privacy: .private, maskQueryLiterals: true) + let query = """ + query GetUser($userId: ID!, $includeEmail: Boolean!) { + user(id: $userId) { + name + email @include(if: $includeEmail) + } + } + """ + + let entry = AnalyticEntry( + type: .request(method: "POST", url: "https://api.example.com/graphql"), + operationName: "GetUser", + variables: ["userId": "123", "includeEmail": true], + query: query, + configuration: config + ) + + // Variable references should be preserved + XCTAssertTrue(entry.query?.contains("$userId") ?? false) + XCTAssertTrue(entry.query?.contains("$includeEmail") ?? false) + } + + func testQueryNilWithSensitivePrivacy() { + let config = AnalyticsConfiguration(privacy: .sensitive) + let query = "query GetUser { user { name } }" + + let entry = AnalyticEntry( + type: .request(method: "POST", url: "https://api.example.com/graphql"), + operationName: "GetUser", + query: query, + configuration: config + ) + + // Query should be nil with sensitive privacy + XCTAssertNil(entry.query) + } + + func testQueryNilWhenNotProvided() { + let config = AnalyticsConfiguration(privacy: .private) + + let entry = AnalyticEntry( + type: .request(method: "GET", url: "https://api.example.com/users"), + configuration: config + ) + + // Query should be nil for non-GraphQL requests + XCTAssertNil(entry.query) + } + + func testComplexQueryMasking() { + let config = AnalyticsConfiguration(privacy: .private, maskQueryLiterals: true) + let query = """ + query GetUserData($userId: ID!, $limit: Int!) { + user(id: $userId) { + name + posts(limit: $limit, status: "published", minLikes: 10) { + title + content + tags(filter: "technology") + } + } + } + """ + + let entry = AnalyticEntry( + type: .request(method: "POST", url: "https://api.example.com/graphql"), + operationName: "GetUserData", + query: query, + configuration: config + ) + + let maskedQuery = entry.query! + + // Variable references preserved + XCTAssertTrue(maskedQuery.contains("$userId")) + XCTAssertTrue(maskedQuery.contains("$limit")) + + // String literals masked + XCTAssertFalse(maskedQuery.contains("\"published\"")) + XCTAssertFalse(maskedQuery.contains("\"technology\"")) + XCTAssertTrue(maskedQuery.contains("status: \"***\"")) + XCTAssertTrue(maskedQuery.contains("filter: \"***\"")) + + // Number literals masked + XCTAssertFalse(maskedQuery.contains("10")) + XCTAssertTrue(maskedQuery.contains("minLikes: ***")) + + // Structure preserved + XCTAssertTrue(maskedQuery.contains("GetUserData")) + XCTAssertTrue(maskedQuery.contains("user")) + XCTAssertTrue(maskedQuery.contains("posts")) + XCTAssertTrue(maskedQuery.contains("tags")) + } + + func testQueryMasksStringLiteralsContainingSpecialCharacters() { + let config = AnalyticsConfiguration(privacy: .private, maskQueryLiterals: true) + let query = """ + query Test { + user(id: "$123&45", email: "user@example.com", filter: "status:active") { + name + } + } + """ + + let entry = AnalyticEntry( + type: .request(method: "POST", url: "https://api.example.com/graphql"), + operationName: "Test", + query: query, + configuration: config + ) + + let maskedQuery = entry.query! + + // String literals should be masked even if they contain special characters + XCTAssertFalse(maskedQuery.contains("\"$123&45\"")) + XCTAssertFalse(maskedQuery.contains("\"user@example.com\"")) + XCTAssertFalse(maskedQuery.contains("\"status:active\"")) + + // All string values should be masked + XCTAssertTrue(maskedQuery.contains("id: \"***\"")) + XCTAssertTrue(maskedQuery.contains("email: \"***\"")) + XCTAssertTrue(maskedQuery.contains("filter: \"***\"")) + + // Structure preserved + XCTAssertTrue(maskedQuery.contains("query Test")) + XCTAssertTrue(maskedQuery.contains("user")) + XCTAssertTrue(maskedQuery.contains("name")) + } } diff --git a/Tests/FTNetworkTracerTests/IntegrationTests.swift b/Tests/FTNetworkTracerTests/IntegrationTests.swift index 9cfb849..fa93e4b 100644 --- a/Tests/FTNetworkTracerTests/IntegrationTests.swift +++ b/Tests/FTNetworkTracerTests/IntegrationTests.swift @@ -160,6 +160,44 @@ class IntegrationTests: XCTestCase { XCTAssertTrue(entry.error!.contains("Query failed")) } + func testGraphQLAnalyticsWithQuery() { + let analytics = MockAnalytics(configuration: AnalyticsConfiguration(privacy: .private)) + let tracer = FTNetworkTracer(logger: nil, analytics: analytics) + + let query = """ + query GetUser($id: ID!) { + user(id: $id, role: "admin") { + name + email + } + } + """ + let variables: [String: any Sendable] = ["id": "123"] + + tracer.logAndTrackRequest( + url: "https://api.example.com/graphql", + operationName: "GetUser", + query: query, + variables: variables, + headers: ["Authorization": "Bearer token"], + requestId: "test-request" + ) + + XCTAssertEqual(analytics.trackedEntries.count, 1) + let entry = analytics.trackedEntries[0] + XCTAssertEqual(entry.operationName, "GetUser") + + // Query should be included and masked by default + XCTAssertNotNil(entry.query) + XCTAssertTrue(entry.query?.contains("GetUser") ?? false) + XCTAssertTrue(entry.query?.contains("$id") ?? false) // Variable reference preserved + XCTAssertFalse(entry.query?.contains("\"admin\"") ?? true) // Literal masked + XCTAssertTrue(entry.query?.contains("role: \"***\"") ?? false) + + // Variables should be masked + XCTAssertNotNil(entry.variables) + } + // MARK: - Privacy Integration Tests func testPrivacyMaskingInAnalytics() { diff --git a/Tests/FTNetworkTracerTests/SecurityTests.swift b/Tests/FTNetworkTracerTests/SecurityTests.swift index 7973089..89b0bc2 100644 --- a/Tests/FTNetworkTracerTests/SecurityTests.swift +++ b/Tests/FTNetworkTracerTests/SecurityTests.swift @@ -520,4 +520,162 @@ class SecurityTests: XCTestCase { XCTAssertEqual(secretTokens, ["***", "***", "***"]) } } + + // MARK: - GraphQL Query Masking Security + + func testGraphQLQueryMaskingPreventsDataLeakage() { + let config = AnalyticsConfiguration(privacy: .private) + + // Query with PII in literals + let query = """ + query GetUser { + user(email: "john.doe@example.com", ssn: "123-45-6789", creditCard: "4111111111111111") { + name + profile(phone: "+1-555-123-4567") + } + } + """ + + let entry = AnalyticEntry( + type: .request(method: "POST", url: "https://api.example.com/graphql"), + operationName: "GetUser", + query: query, + configuration: config + ) + + XCTAssertNotNil(entry.query) + let maskedQuery = entry.query! + + // Verify PII is masked + XCTAssertFalse(maskedQuery.contains("john.doe@example.com"), "Email should be masked") + XCTAssertFalse(maskedQuery.contains("123-45-6789"), "SSN should be masked") + XCTAssertFalse(maskedQuery.contains("4111111111111111"), "Credit card should be masked") + XCTAssertFalse(maskedQuery.contains("+1-555-123-4567"), "Phone should be masked") + + // Verify structure is preserved + XCTAssertTrue(maskedQuery.contains("GetUser")) + XCTAssertTrue(maskedQuery.contains("user")) + XCTAssertTrue(maskedQuery.contains("profile")) + + // Verify masking is applied + XCTAssertTrue(maskedQuery.contains("\"***\"")) + } + + func testGraphQLQueryMaskingSQLInjection() { + let config = AnalyticsConfiguration(privacy: .private) + + let sqlInjection = "' OR '1'='1" + let query = """ + query MaliciousQuery { + user(id: "\(sqlInjection)") { + name + } + } + """ + + let entry = AnalyticEntry( + type: .request(method: "POST", url: "https://api.example.com/graphql"), + operationName: "MaliciousQuery", + query: query, + configuration: config + ) + + XCTAssertNotNil(entry.query) + let maskedQuery = entry.query! + + // SQL injection attempt should be masked + XCTAssertFalse(maskedQuery.contains(sqlInjection)) + XCTAssertTrue(maskedQuery.contains("id: \"***\"")) + + // Structure preserved + XCTAssertTrue(maskedQuery.contains("MaliciousQuery")) + XCTAssertTrue(maskedQuery.contains("user")) + } + + func testGraphQLQueryMaskingXSS() { + let config = AnalyticsConfiguration(privacy: .private) + + let xssPayload = "" + let query = """ + query XSSAttempt { + comment(text: "\(xssPayload)", author: "attacker") { + id + } + } + """ + + let entry = AnalyticEntry( + type: .request(method: "POST", url: "https://api.example.com/graphql"), + operationName: "XSSAttempt", + query: query, + configuration: config + ) + + XCTAssertNotNil(entry.query) + let maskedQuery = entry.query! + + // XSS payload should be masked + XCTAssertFalse(maskedQuery.contains(xssPayload)) + XCTAssertFalse(maskedQuery.contains("script")) + XCTAssertFalse(maskedQuery.contains("alert")) + + // Both string literals should be masked + XCTAssertTrue(maskedQuery.contains("text: \"***\"")) + XCTAssertTrue(maskedQuery.contains("author: \"***\"")) + + // Structure preserved + XCTAssertTrue(maskedQuery.contains("comment")) + } + + func testMalformedGraphQLQueryWithUnclosedString() { + let config = AnalyticsConfiguration(privacy: .private) + + // Malformed query with unclosed string literal (security vulnerability test) + let malformedQuery = """ + query Test { + user(id: $userId, secret: "password123 + } + """ + + let entry = AnalyticEntry( + type: .request(method: "POST", url: "https://api.example.com/graphql"), + operationName: "Test", + query: malformedQuery, + configuration: config + ) + + XCTAssertNotNil(entry.query) + let maskedQuery = entry.query! + + // Critical: Unclosed string content must be masked, not leaked + XCTAssertFalse(maskedQuery.contains("password123"), "Unclosed string content must be masked") + + // Should contain masked marker for safety + XCTAssertTrue(maskedQuery.contains("\"***\"")) + + // Variable reference should still be preserved + XCTAssertTrue(maskedQuery.contains("$userId")) + } + + func testGraphQLQueryNilInSensitiveMode() { + let config = AnalyticsConfiguration(privacy: .sensitive) + + let query = """ + query GetUser { + user(id: "123", role: "admin") { + name + } + } + """ + + let entry = AnalyticEntry( + type: .request(method: "POST", url: "https://api.example.com/graphql"), + operationName: "GetUser", + query: query, + configuration: config + ) + + // In sensitive mode, query should be completely blocked + XCTAssertNil(entry.query, "Query should be nil in sensitive privacy mode") + } }