Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
74 changes: 65 additions & 9 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`)
🔒 **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,13 +346,15 @@ 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

**Total: 75 tests** with full coverage of privacy, security, and GraphQL query masking

## Example Projects

### URLSession Integration
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)
}
}
148 changes: 148 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,145 @@ public struct AnalyticsConfiguration: Sendable {
return "***"
}
}

func maskQuery(_ query: String?) -> String? {
guard let query else {
return nil
}

switch privacy {
case .none, .private:
return maskQueryLiterals ? maskQueryLiteralValues(query) : query
case .sensitive:
return nil
}
}

private struct QueryMaskingState {
var result = ""
var insideString = false
var insideParentheses = false
var currentToken = ""
var escapeNext = false
}

private func maskQueryLiteralValues(_ query: String) -> String {
var state = QueryMaskingState()

for char in query {
if state.escapeNext {
handleEscapedCharacter(char, state: &state)
continue
}

if char == "\\" {
handleBackslash(char, state: &state)
continue
}

processCharacter(char, state: &state)
}

state.result.append(state.currentToken)
return state.result
}

private func handleEscapedCharacter(_ char: Character, state: inout QueryMaskingState) {
if state.insideString {
state.currentToken.append(char)
} else {
state.result.append(char)
}
state.escapeNext = false
}

private func handleBackslash(_ char: Character, state: inout QueryMaskingState) {
state.escapeNext = true
if state.insideString {
state.currentToken.append(char)
} else {
state.result.append(char)
}
}

private func processCharacter(_ char: Character, state: inout QueryMaskingState) {
switch char {
case "\"":
handleQuote(state: &state)
case "(":
handleOpenParenthesis(char, state: &state)
case ")":
handleCloseParenthesis(char, state: &state)
case " ", "\n", "\t", ",", ":":
handleDelimiter(char, state: &state)
default:
handleDefaultCharacter(char, state: &state)
}
}

private func handleQuote(state: inout QueryMaskingState) {
if state.insideParentheses && !state.insideString {
state.insideString = true
state.currentToken = "\""
} else if state.insideString {
state.insideString = false
state.result.append("\"***\"")
state.currentToken = ""
} else {
state.result.append("\"")
}
}

private func handleOpenParenthesis(_ char: Character, state: inout QueryMaskingState) {
state.result.append(state.currentToken)
state.result.append(char)
state.currentToken = ""
state.insideParentheses = true
}

private func handleCloseParenthesis(_ char: Character, state: inout QueryMaskingState) {
flushToken(state: &state)
state.result.append(char)
state.currentToken = ""
state.insideParentheses = false
}

private func handleDelimiter(_ char: Character, state: inout QueryMaskingState) {
if state.insideString {
state.currentToken.append(char)
} else {
flushToken(state: &state)
state.result.append(char)
}
}

private func handleDefaultCharacter(_ char: Character, state: inout QueryMaskingState) {
if state.insideString || state.insideParentheses {
state.currentToken.append(char)
} else {
state.result.append(char)
}
}

private func flushToken(state: inout QueryMaskingState) {
guard state.insideParentheses && !state.currentToken.isEmpty else {
return
}

if isNumericLiteral(state.currentToken) {
state.result.append("***")
} else {
state.result.append(state.currentToken)
}
state.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
}
}
1 change: 1 addition & 0 deletions Sources/FTNetworkTracer/FTNetworkTracer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ public class FTNetworkTracer {
requestId: requestId,
operationName: operationName,
variables: variables,
query: query,
configuration: analytics.configuration
)
analytics.track(analyticEntry)
Expand Down
Loading