Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
76 changes: 66 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
}

Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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**:
Expand Down Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions Sources/FTNetworkTracer/Analytics/AnalyticEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -50,5 +52,6 @@ public struct AnalyticEntry: NetworkEntry {
self.requestId = requestId
self.operationName = operationName
self.variables = configuration.maskVariables(variables)
self.query = configuration.maskQuery(query)
}
}
20 changes: 20 additions & 0 deletions Sources/FTNetworkTracer/Analytics/AnalyticsConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>
private let unmaskedUrlQueries: Set<String>
private let unmaskedBodyParams: Set<String>
Expand All @@ -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<String> = [],
unmaskedUrlQueries: Set<String> = [],
unmaskedBodyParams: Set<String> = []
) {
self.privacy = privacy
self.maskQueryLiterals = maskQueryLiterals
self.unmaskedHeaders = unmaskedHeaders
self.unmaskedUrlQueries = unmaskedUrlQueries
self.unmaskedBodyParams = unmaskedBodyParams
Expand Down Expand Up @@ -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
}
}
}
164 changes: 164 additions & 0 deletions Sources/FTNetworkTracer/Analytics/QueryLiteralMasker.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading