Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RUM-872 Keep Secured Text Hidden #2050

Merged
merged 2 commits into from
Sep 16, 2024
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Unreleased

- [IMPROVEMENT] Add overwrite required (breaking) param to addViewLoadingTime & usage telemetry. See [#2040][]
- [FEATURE] Prevent "show password" features from revealing sensitive texts. See [#2050][]

# 2.17.0 / 11-09-2024

Expand Down Expand Up @@ -771,6 +772,7 @@ Release `2.0` introduces breaking changes. Follow the [Migration Guide](MIGRATIO
[#2026]: https://github.com/DataDog/dd-sdk-ios/pull/2026
[#2043]: https://github.com/DataDog/dd-sdk-ios/pull/2043
[#2040]: https://github.com/DataDog/dd-sdk-ios/pull/2040
[#2050]: https://github.com/DataDog/dd-sdk-ios/pull/2050
[@00fa9a]: https://github.com/00FA9A
[@britton-earnin]: https://github.com/Britton-Earnin
[@hengyu]: https://github.com/Hengyu
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,42 +6,62 @@

#if os(iOS)
import UIKit
import DatadogInternal

internal extension UITraitEnvironment {
var usesDarkMode: Bool {
return traitCollection.userInterfaceStyle == .dark
}
}
extension UIView: DatadogExtended { }

/// Sensitive text content types as defined in Session Replay.
internal let sensitiveContentTypes: Set<UITextContentType> = {
return [
.password,
.emailAddress,
.telephoneNumber,
.addressCity, .addressState, .addressCityAndState, .fullStreetAddress, .streetAddressLine1, .streetAddressLine2, .postalCode,
.creditCardNumber,
.newPassword,
.oneTimeCode,
]
}()

internal extension UITextInputTraits {
private let UITextContentSensitiveTypes: Set<UITextContentType> = [
.password,
.emailAddress,
.telephoneNumber,
.addressCity, .addressState, .addressCityAndState, .fullStreetAddress, .streetAddressLine1, .streetAddressLine2, .postalCode,
.creditCardNumber,
.newPassword,
.oneTimeCode,
]

private var UITextInputTraitsIsSensitiveTextKey: UInt8 = 0

internal extension DatadogExtension where ExtendedType: UITextInputTraits {
/// Sensitive text content types as defined in Session Replay.
static var sensitiveTypes: Set<UITextContentType> {
UITextContentSensitiveTypes
}

/// Whether or not these input traits describe a "Sensitive Text".
///
/// The input traits will still be considered sensitive if its sensitivity or its
/// content type change.
///
/// In Session Replay, "Sensitive Text" is:
/// - passwords, e-mails and phone numbers marked in a platform-specific way
/// - AND other forms of sensitivity in text available to each platform
var isSensitiveText: Bool {
if isSecureTextEntry == true {
if objc_getAssociatedObject(type, &UITextInputTraitsIsSensitiveTextKey) as? Bool == true {
return true
}

if type.isSecureTextEntry == true {
objc_setAssociatedObject(type, &UITextInputTraitsIsSensitiveTextKey, true, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
return true
}

if let contentType = textContentType, let contentType = contentType {
return sensitiveContentTypes.contains(contentType)
let isSensitiveContentType = type.textContentType.map {
UITextContentSensitiveTypes.contains($0)
}

if isSensitiveContentType == true {
objc_setAssociatedObject(type, &UITextInputTraitsIsSensitiveTextKey, true, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
return true
}

return false
}
}

internal extension DatadogExtension where ExtendedType: UITraitEnvironment {
var usesDarkMode: Bool { type.traitCollection.userInterfaceStyle == .dark }
}

#endif
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ internal struct UISwitchRecorder: NodeRecorder {
trackWireframeID: ids[1],
thumbWireframeID: ids[2],
isEnabled: `switch`.isEnabled,
isDarkMode: `switch`.usesDarkMode,
isDarkMode: `switch`.dd.usesDarkMode,
isOn: `switch`.isOn,
isMasked: context.recorder.textAndInputPrivacy.shouldMaskInputElements,
thumbTintColor: `switch`.thumbTintColor?.cgColor,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ internal struct UITextFieldRecorder: NodeRecorder {
isPlaceholderText: isPlaceholder,
font: textField.font,
fontScalingEnabled: textField.adjustsFontSizeToFitWidth,
textObfuscator: textObfuscator(context, textField.isSensitiveText, isPlaceholder)
textObfuscator: textObfuscator(context, textField.dd.isSensitiveText, isPlaceholder)
)
return Node(viewAttributes: attributes, wireframesBuilder: builder)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ internal struct UITextViewRecorder: NodeRecorder {
textAlignment: textView.textAlignment,
textColor: textView.textColor?.cgColor ?? UIColor.black.cgColor,
font: textView.font,
textObfuscator: textObfuscator(context, textView.isSensitiveText, textView.isEditable),
textObfuscator: textObfuscator(context, textView.dd.isSensitiveText, textView.isEditable),
contentRect: CGRect(origin: textView.contentOffset, size: textView.contentSize)
)
let node = Node(viewAttributes: attributes, wireframesBuilder: builder)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@
#if os(iOS)
import XCTest
import UIKit
import DatadogInternal
import TestUtilities
@testable import DatadogSessionReplay

class UIKitExtensionsTests: XCTestCase {
func testUsesDarkMode() {
guard #available(iOS 13.0, *) else {
XCTAssertFalse(UIView().usesDarkMode) // always false prior to iOS 13.x
XCTAssertFalse(UIView().dd.usesDarkMode) // always false prior to iOS 13.x
return
}
class MockView: NSObject, UITraitEnvironment {
class MockView: NSObject, DatadogExtended, UITraitEnvironment {
var traitCollection: UITraitCollection = .init(userInterfaceStyle: .unspecified)
func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {}
}
Expand All @@ -30,26 +31,26 @@ class UIKitExtensionsTests: XCTestCase {
darkView.traitCollection = .init(userInterfaceStyle: .dark)

// Then
XCTAssertFalse(lightView.usesDarkMode)
XCTAssertTrue(darkView.usesDarkMode)
XCTAssertFalse(lightView.dd.usesDarkMode)
XCTAssertTrue(darkView.dd.usesDarkMode)
}

// swiftlint:disable opening_brace
func testIsSensitiveText() {
class Mock: NSObject, UITextInputTraits {
class Mock: NSObject, DatadogExtended, UITextInputTraits {
var isSecureTextEntry = false
var textContentType: UITextContentType! = nil // swiftlint:disable:this implicitly_unwrapped_optional
}

// Given
let sensitiveTextMock = Mock()
let nonSensitiveTextMock = Mock()
let nonSensitiveContentTypes = UITextContentType.allCases.subtracting(sensitiveContentTypes)
let nonSensitiveContentTypes = UITextContentType.allCases.subtracting(Mock.dd.sensitiveTypes)

// When
oneOrMoreOf([
{ sensitiveTextMock.isSecureTextEntry = true },
{ sensitiveTextMock.textContentType = sensitiveContentTypes.randomElement() },
{ sensitiveTextMock.textContentType = Mock.dd.sensitiveTypes.randomElement() },
])
oneOrMoreOf([
{ nonSensitiveTextMock.isSecureTextEntry = false },
Expand All @@ -58,8 +59,8 @@ class UIKitExtensionsTests: XCTestCase {
])

// Then
XCTAssertTrue(sensitiveTextMock.isSensitiveText)
XCTAssertFalse(nonSensitiveTextMock.isSensitiveText)
XCTAssertTrue(sensitiveTextMock.dd.isSensitiveText)
XCTAssertFalse(nonSensitiveTextMock.dd.isSensitiveText)
}
// swiftlint:enable opening_brace
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ class UITextFieldRecorderTests: XCTestCase {
/// `ViewAttributes` simulating common attributes of text field's `UIView`.
private var viewAttributes: ViewAttributes = .mockAny()

private func textObfuscator(in privacyMode: TextAndInputPrivacyLevel) throws -> TextObfuscating {
try recorder
.semantics(of: textField, with: viewAttributes, in: .mockWith(recorder: .mockWith(textAndInputPrivacy: privacyMode)))
.expectWireframeBuilder(ofType: UITextFieldWireframesBuilder.self)
.textObfuscator
}

func testWhenTextFieldIsNotVisible() throws {
// When
viewAttributes = .mock(fixture: .invisible)
Expand Down Expand Up @@ -69,40 +76,36 @@ class UITextFieldRecorderTests: XCTestCase {
XCTAssertNil(recorder.semantics(of: view, with: viewAttributes, in: .mockAny()))
}

func testTextObfuscationInDifferentPrivacyModes() throws {
func testTextObfuscationOfNoSensitiveText() throws {
// When
textField.text = .mockRandom()
textField.isSecureTextEntry = false // non-sensitive
textField.textContentType = nil // non-sensitive
viewAttributes = .mock(fixture: .visible())

// Then
func textObfuscator(in privacyMode: TextAndInputPrivacyLevel) throws -> TextObfuscating {
return try recorder
.semantics(of: textField, with: viewAttributes, in: .mockWith(recorder: .mockWith(textAndInputPrivacy: privacyMode)))
.expectWireframeBuilder(ofType: UITextFieldWireframesBuilder.self)
.textObfuscator
}

XCTAssertTrue(try textObfuscator(in: .maskSensitiveInputs) is NOPTextObfuscator)
XCTAssertTrue(try textObfuscator(in: .maskAllInputs) is FixLengthMaskObfuscator)
XCTAssertTrue(try textObfuscator(in: .maskAll) is FixLengthMaskObfuscator)
}

func testTextObfuscationOfSensitiveText() throws {
// When
textField.text = .mockRandom()
oneOrMoreOf([
{ self.textField.isSecureTextEntry = true },
{ self.textField.textContentType = sensitiveContentTypes.randomElement() },
{ self.textField.textContentType = UITextField.dd.sensitiveTypes.randomElement() },
])

// Then
XCTAssertTrue(try textObfuscator(in: .mockRandom()) is FixLengthMaskObfuscator)

// When
textField.text = nil
textField.placeholder = .mockRandom()
textField.isSecureTextEntry = false // non-sensitive
textField.textContentType = nil // non-sensitive

// Then
XCTAssertTrue(try textObfuscator(in: .maskSensitiveInputs) is NOPTextObfuscator)
XCTAssertTrue(try textObfuscator(in: .maskAllInputs) is NOPTextObfuscator)
XCTAssertTrue(try textObfuscator(in: .maskAll) is FixLengthMaskObfuscator)
// Then - it keeps obfuscating
XCTAssertTrue(try textObfuscator(in: .mockRandom()) is FixLengthMaskObfuscator)
}
}
// swiftlint:enable opening_brace
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ class UITextViewRecorderTests: XCTestCase {
/// `ViewAttributes` simulating common attributes of text view's `UIView`.
private var viewAttributes: ViewAttributes = .mockAny()

private func textObfuscator(in privacyMode: TextAndInputPrivacyLevel) throws -> TextObfuscating {
try recorder
.semantics(of: textView, with: viewAttributes, in: .mockWith(recorder: .mockWith(textAndInputPrivacy: privacyMode)))
.expectWireframeBuilder(ofType: UITextViewWireframesBuilder.self)
.textObfuscator
}

func testWhenTextViewIsNotVisible() throws {
// When
viewAttributes = .mock(fixture: .invisible)
Expand Down Expand Up @@ -58,28 +65,26 @@ class UITextViewRecorderTests: XCTestCase {
XCTAssertNil(recorder.semantics(of: view, with: viewAttributes, in: .mockAny()))
}

func testTextObfuscationInDifferentPrivacyModes() throws {
func testTextObfuscationOfNoSensitiveText() throws {
// When
textView.text = .mockRandom()
textView.isSecureTextEntry = false // non-sensitive
textView.textContentType = nil // non-sensitive
viewAttributes = .mock(fixture: .visible())

// Then
func textObfuscator(in privacyMode: TextAndInputPrivacyLevel) throws -> TextObfuscating {
return try recorder
.semantics(of: textView, with: viewAttributes, in: .mockWith(recorder: .mockWith(textAndInputPrivacy: privacyMode)))
.expectWireframeBuilder(ofType: UITextViewWireframesBuilder.self)
.textObfuscator
}

XCTAssertTrue(try textObfuscator(in: .maskSensitiveInputs) is NOPTextObfuscator)
XCTAssertTrue(try textObfuscator(in: .maskAllInputs) is FixLengthMaskObfuscator)
XCTAssertTrue(try textObfuscator(in: .maskAll) is FixLengthMaskObfuscator)
}

func testTextObfuscationOfSensitiveText() throws {
// When
textView.text = .mockRandom()
textView.isEditable = .mockRandom()
oneOrMoreOf([
{ self.textView.isSecureTextEntry = true },
{ self.textView.textContentType = sensitiveContentTypes.randomElement() },
{ self.textView.textContentType = UITextView.dd.sensitiveTypes.randomElement() },
])

// Then
Expand All @@ -90,10 +95,8 @@ class UITextViewRecorderTests: XCTestCase {
textView.isSecureTextEntry = false // non-sensitive
textView.textContentType = nil // non-sensitive

// Then
XCTAssertTrue(try textObfuscator(in: .maskSensitiveInputs) is NOPTextObfuscator)
XCTAssertTrue(try textObfuscator(in: .maskAllInputs) is NOPTextObfuscator)
XCTAssertTrue(try textObfuscator(in: .maskAll) is SpacePreservingMaskObfuscator)
// Then - it keeps obfuscating
XCTAssertTrue(try textObfuscator(in: .mockRandom()) is FixLengthMaskObfuscator)
}
}
// swiftlint:enable opening_brace
Expand Down