diff --git a/Analytics/Classes/Internal/SEGUtils.h b/Analytics/Classes/Internal/SEGUtils.h index 761fed5dd..877fbe193 100644 --- a/Analytics/Classes/Internal/SEGUtils.h +++ b/Analytics/Classes/Internal/SEGUtils.h @@ -12,4 +12,6 @@ + (NSData *_Nullable)dataFromPlist:(nonnull id)plist; + (id _Nullable)plistFromData:(NSData *_Nonnull)data; ++ (id _Nullable)traverseJSON:(id _Nullable)object andReplaceWithFilters:(nonnull NSDictionary<NSString*, NSString*>*)patterns; + @end diff --git a/Analytics/Classes/Internal/SEGUtils.m b/Analytics/Classes/Internal/SEGUtils.m index 8f350fae2..afbc01418 100644 --- a/Analytics/Classes/Internal/SEGUtils.m +++ b/Analytics/Classes/Internal/SEGUtils.m @@ -34,4 +34,62 @@ + (id _Nullable)plistFromData:(NSData *_Nonnull)data return plist; } + ++(id)traverseJSON:(id)object andReplaceWithFilters:(NSDictionary<NSString*, NSString*>*)patterns +{ + if (object == nil || object == NSNull.null || [object isKindOfClass:NSNull.class]) { + return object; + } + + if ([object isKindOfClass:NSDictionary.class]) { + NSDictionary* dict = object; + NSMutableDictionary* newDict = [NSMutableDictionary dictionaryWithCapacity:dict.count]; + + for (NSString* key in dict.allKeys) { + newDict[key] = [self traverseJSON:dict[key] andReplaceWithFilters:patterns]; + } + + return newDict; + } + + if ([object isKindOfClass:NSArray.class]) { + NSArray* array = object; + NSMutableArray* newArray = [NSMutableArray arrayWithCapacity:array.count]; + + for (int i = 0; i < array.count; i++) { + newArray[i] = [self traverseJSON:array[i] andReplaceWithFilters:patterns]; + } + + return newArray; + } + + if ([object isKindOfClass:NSString.class]) { + NSError* error = nil; + NSMutableString* str = [object mutableCopy]; + + for (NSString* pattern in patterns) { + NSRegularExpression* re = [NSRegularExpression regularExpressionWithPattern:pattern + options:0 + error:&error]; + + if (error) { + @throw error; + } + + NSInteger matches = [re replaceMatchesInString:str + options:0 + range:NSMakeRange(0, str.length) + withTemplate:patterns[pattern]]; + + if (matches > 0) { + SEGLog(@"%@ Redacted value from action: %@", self, pattern); + } + } + + return str; + } + + return object; +} + @end diff --git a/Analytics/Classes/SEGAnalytics.m b/Analytics/Classes/SEGAnalytics.m index 671611515..5c9b94c2d 100644 --- a/Analytics/Classes/SEGAnalytics.m +++ b/Analytics/Classes/SEGAnalytics.m @@ -14,6 +14,7 @@ #import "SEGMiddleware.h" #import "SEGContext.h" #import "SEGIntegrationsManager.h" +#import "Internal/SEGUtils.h" static SEGAnalytics *__sharedInstance = nil; @@ -344,6 +345,8 @@ - (void)continueUserActivity:(NSUserActivity *)activity [properties addEntriesFromDictionary:activity.userInfo]; properties[@"url"] = activity.webpageURL.absoluteString; properties[@"title"] = activity.title ?: @""; + properties = [SEGUtils traverseJSON:properties + andReplaceWithFilters:self.configuration.payloadFilters]; [self track:@"Deep Link Opened" properties:[properties copy]]; } } @@ -351,7 +354,8 @@ - (void)continueUserActivity:(NSUserActivity *)activity - (void)openURL:(NSURL *)url options:(NSDictionary *)options { SEGOpenURLPayload *payload = [[SEGOpenURLPayload alloc] init]; - payload.url = url; + payload.url = [NSURL URLWithString:[SEGUtils traverseJSON:url.absoluteString + andReplaceWithFilters:self.configuration.payloadFilters]]; payload.options = options; [self run:SEGEventTypeOpenURL payload:payload]; @@ -362,6 +366,8 @@ - (void)openURL:(NSURL *)url options:(NSDictionary *)options NSMutableDictionary *properties = [NSMutableDictionary dictionaryWithCapacity:options.count + 2]; [properties addEntriesFromDictionary:options]; properties[@"url"] = url.absoluteString; + properties = [SEGUtils traverseJSON:properties + andReplaceWithFilters:self.configuration.payloadFilters]; [self track:@"Deep Link Opened" properties:[properties copy]]; } diff --git a/Analytics/Classes/SEGAnalyticsConfiguration.h b/Analytics/Classes/SEGAnalyticsConfiguration.h index bbdc7a815..8fc5714ab 100644 --- a/Analytics/Classes/SEGAnalyticsConfiguration.h +++ b/Analytics/Classes/SEGAnalyticsConfiguration.h @@ -140,4 +140,30 @@ typedef NSMutableURLRequest *_Nonnull (^SEGRequestFactory)(NSURL *_Nonnull); */ @property (nonatomic, strong, nullable) id<SEGApplicationProtocol> application; +/** + * A dictionary of filters to redact payloads before they are sent. + * This is an experimental feature that currently only applies to Deep Links. + * It is subject to change to allow for more flexible customizations in the future. + * + * The key of this dictionary should be a regular expression string pattern, + * and the value should be a regular expression substitution template. + * + * By default, this contains a Facebook auth token filter, configured as such: + * @code + * @"(fb\\d+://authorize#access_token=)([^ ]+)": @"$1((redacted/fb-auth-token))" + * @endcode + * + * This will replace any matching occurences to a redacted version: + * @code + * "fb123456789://authorize#access_token=secretsecretsecretsecret&some=data" + * @endcode + * + * Becomes: + * @code + * "fb123456789://authorize#access_token=((redacted/fb-auth-token))" + * @endcode + * + */ +@property (nonatomic, strong, nonnull) NSDictionary<NSString*, NSString*>* payloadFilters; + @end diff --git a/Analytics/Classes/SEGAnalyticsConfiguration.m b/Analytics/Classes/SEGAnalyticsConfiguration.m index c95e0a352..def91e3a6 100644 --- a/Analytics/Classes/SEGAnalyticsConfiguration.m +++ b/Analytics/Classes/SEGAnalyticsConfiguration.m @@ -57,6 +57,9 @@ - (instancetype)init self.flushAt = 20; self.flushInterval = 30; self.maxQueueSize = 1000; + self.payloadFilters = @{ + @"(fb\\d+://authorize#access_token=)([^ ]+)": @"$1((redacted/fb-auth-token))" + }; _factories = [NSMutableArray array]; Class applicationClass = NSClassFromString(@"UIApplication"); if (applicationClass) { diff --git a/AnalyticsTests/AnalyticsTests-Bridging-Header.h b/AnalyticsTests/AnalyticsTests-Bridging-Header.h index 58aa5b215..877104033 100644 --- a/AnalyticsTests/AnalyticsTests-Bridging-Header.h +++ b/AnalyticsTests/AnalyticsTests-Bridging-Header.h @@ -11,6 +11,7 @@ #import <Analytics/UIViewController+SEGScreen.h> #import <Analytics/SEGAnalyticsUtils.h> #import <Analytics/SEGIntegrationsManager.h> +#import <Analytics/SEGUtils.h> #import "NSData+SEGGUNZIPP.h" // Temp hack. We should fix the LSNocilla podspec to make this header publicly available diff --git a/AnalyticsTests/AnalyticsTests.swift b/AnalyticsTests/AnalyticsTests.swift index 2d9f55fa6..7c6acaafa 100644 --- a/AnalyticsTests/AnalyticsTests.swift +++ b/AnalyticsTests/AnalyticsTests.swift @@ -170,6 +170,29 @@ class AnalyticsTests: QuickSpec { expect(timer).toNot(beNil()) expect(timer?.timeInterval) == config.flushInterval } + + it("redacts sensible URLs from deep links tracking") { + testMiddleware.swallowEvent = true + analytics.configuration.trackDeepLinks = true + analytics.open(URL(string: "fb123456789://authorize#access_token=hastoberedacted")!, options: [:]) + + + let event = testMiddleware.lastContext?.payload as? SEGTrackPayload + expect(event?.event) == "Deep Link Opened" + expect(event?.properties?["url"] as? String) == "fb123456789://authorize#access_token=((redacted/fb-auth-token))" + } + + it("redacts sensible URLs from deep links tracking using custom filters") { + testMiddleware.swallowEvent = true + analytics.configuration.payloadFilters["(myapp://auth\\?token=)([^&]+)"] = "$1((redacted/my-auth))" + analytics.configuration.trackDeepLinks = true + analytics.open(URL(string: "myapp://auth?token=hastoberedacted&other=stuff")!, options: [:]) + + + let event = testMiddleware.lastContext?.payload as? SEGTrackPayload + expect(event?.event) == "Deep Link Opened" + expect(event?.properties?["url"] as? String) == "myapp://auth?token=((redacted/my-auth))&other=stuff" + } } } diff --git a/AnalyticsTests/AnalyticsUtilTests.swift b/AnalyticsTests/AnalyticsUtilTests.swift index ba6f7d691..f1841fb19 100644 --- a/AnalyticsTests/AnalyticsUtilTests.swift +++ b/AnalyticsTests/AnalyticsUtilTests.swift @@ -62,5 +62,54 @@ class AnalyticsUtilTests: QuickSpec { expect(queue) == [1, 2, 3, 4, 5] } }) + + describe("JSON traverse", { + let filters = [ + "(foo)": "$1-bar" + ] + + func equals(a: Any, b: Any) -> Bool { + let aData = try! JSONSerialization.data(withJSONObject: a, options: .prettyPrinted) as NSData + let bData = try! JSONSerialization.data(withJSONObject: b, options: .prettyPrinted) + + return aData.isEqual(to: bData) + } + + it("works with strings") { + expect(SEGUtils.traverseJSON("a b foo c", andReplaceWithFilters: filters) as? String) == "a b foo-bar c" + } + + it("works recursively") { + expect(SEGUtils.traverseJSON("a b foo foo c", andReplaceWithFilters: filters) as? String) == "a b foo-bar foo-bar c" + } + + it("works with nested dictionaries") { + let data = [ + "foo": [1, nil, "qfoob", ["baz": "foo"]], + "bar": "foo" + ] as [String : Any] + let input = SEGUtils.traverseJSON(data, andReplaceWithFilters: filters) + let output = [ + "foo": [1, nil, "qfoo-barb", ["baz": "foo-bar"]], + "bar": "foo-bar" + ] as [String : Any] + + expect(equals(a: input!, b: output)) == true + } + + it("works with nested arrays") { + let data = [ + [1, nil, "qfoob", ["baz": "foo"]], + "foo" + ] as [Any] + let input = SEGUtils.traverseJSON(data, andReplaceWithFilters: filters) + let output = [ + [1, nil, "qfoo-barb", ["baz": "foo-bar"]], + "foo-bar" + ] as [Any] + + expect(equals(a: input!, b: output)) == true + } + }) } } diff --git a/AnalyticsTests/Utils/TestUtils.swift b/AnalyticsTests/Utils/TestUtils.swift index b6a7f6306..dc4e85823 100644 --- a/AnalyticsTests/Utils/TestUtils.swift +++ b/AnalyticsTests/Utils/TestUtils.swift @@ -67,7 +67,7 @@ extension SEGSegmentIntegration { func test_queue() -> [AnyObject]? { return self.value(forKey: "queue") as? [AnyObject] } - func test_dispatchBackground(block: @convention(block) () -> Void) { + func test_dispatchBackground(block: @escaping @convention(block) () -> Void) { self.perform(Selector(("dispatchBackground:")), with: block) } } diff --git a/Podfile b/Podfile index ea1aed9a3..4c1ed0721 100644 --- a/Podfile +++ b/Podfile @@ -4,7 +4,7 @@ target 'AnalyticsTests' do use_frameworks! pod 'Quick', '~> 1.2.0' - pod 'Nimble', '~> 7.0.3' + pod 'Nimble', '~> 7.3.1' pod 'Nocilla', '~> 0.11.0' pod 'Alamofire', '~> 4.5' pod 'Alamofire-Synchronous', '~> 4.0' diff --git a/Podfile.lock b/Podfile.lock index 141e83b8a..162d71864 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -2,7 +2,7 @@ PODS: - Alamofire (4.6.0) - Alamofire-Synchronous (4.0.0): - Alamofire (~> 4.0) - - Nimble (7.0.3) + - Nimble (7.3.1) - Nocilla (0.11.0) - Quick (1.2.0) - SwiftTryCatch (1.0.0) @@ -10,7 +10,7 @@ PODS: DEPENDENCIES: - Alamofire (~> 4.5) - Alamofire-Synchronous (~> 4.0) - - Nimble (~> 7.0.3) + - Nimble (~> 7.3.1) - Nocilla (~> 0.11.0) - Quick (~> 1.2.0) - SwiftTryCatch (from `https://github.com/segmentio/SwiftTryCatch.git`) @@ -35,11 +35,11 @@ CHECKOUT OPTIONS: SPEC CHECKSUMS: Alamofire: f41a599bd63041760b26d393ec1069d9d7b917f4 Alamofire-Synchronous: eedf1e6e961c3795a63c74990b3f7d9fbfac7e50 - Nimble: 7f5a9c447a33002645a071bddafbfb24ea70e0ac + Nimble: 04f732da099ea4d153122aec8c2a88fd0c7219ae Nocilla: 7af7a386071150cc8aa5da4da97d060f049dd61c Quick: 58d203b1c5e27fff7229c4c1ae445ad7069a7a08 SwiftTryCatch: 2f4ef36cf5396bdb450006b70633dbce5260d3b3 -PODFILE CHECKSUM: 70caa6b2011c61348e6dbbb35d12b64fa7558374 +PODFILE CHECKSUM: cf4abb4263c7b514d71c70514284ac657d90865d COCOAPODS: 1.5.3