From 07176dec8bb917298e674abcf99caf7fac736dec Mon Sep 17 00:00:00 2001 From: Johnny IV Young Ospino Date: Mon, 5 Oct 2026 13:41:50 -0500 Subject: [PATCH 1/4] fix(privacy): inspect every card candidate before classifying clips --- CHANGELOG.md | 3 +++ .../GanchoAI/SensitiveDataDetector.swift | 22 +++++++++++-------- .../SensitiveDataDetectorTests.swift | 15 +++++++++++++ .../ClipItemFactoryTests.swift | 11 ++++++++++ 4 files changed, 42 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f1b0a35..9b42f75a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,9 @@ and release versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Detect a credit card even when an earlier order or reference number fails + its checksum, so mixed clipboard text still gets masking and short expiry. + - Keep the panel's hidden ⌘V, ⌥⌘V, ⌘Y, ⌘G and ⇧⌘C shortcuts away from a field being edited, the board picker and the shortcuts sheet, and stop list keys acting behind that sheet. Escape cancels a title or content edit. diff --git a/Packages/GanchoKit/Sources/GanchoAI/SensitiveDataDetector.swift b/Packages/GanchoKit/Sources/GanchoAI/SensitiveDataDetector.swift index 98dc28d8..226c55a5 100644 --- a/Packages/GanchoKit/Sources/GanchoAI/SensitiveDataDetector.swift +++ b/Packages/GanchoKit/Sources/GanchoAI/SensitiveDataDetector.swift @@ -79,7 +79,7 @@ public struct SensitiveDataDetector: Sendable { return .azureConnectionString } if containsAuthorizationSecret(text) { return .authorizationHeader } - if let card = containedCardCandidate(text), Luhn.validates(card) { + if containsValidCard(text) { return .creditCard } if isProbablePassword(text) { return .probablePassword } @@ -107,15 +107,19 @@ public struct SensitiveDataDetector: Sendable { return matches(#"(?i)\bbearer\s+[A-Za-z0-9\-._~+/]{20,}=*"#, in: text) } - /// 13–19 digit run (spaces/dashes allowed) anywhere in the text. - private func containedCardCandidate(_ text: String) -> String? { - guard - let range = text.range( + /// Checks every 13–19 digit run (spaces/dashes allowed). An order number + /// that fails Luhn must not hide a later card in the same clipboard text. + private func containsValidCard(_ text: String) -> Bool { + var searchRange = text.startIndex.. Date: Mon, 5 Oct 2026 13:45:19 -0500 Subject: [PATCH 2/4] style: align card detection regressions with swift-format --- .../Sources/GanchoAI/SensitiveDataDetector.swift | 4 ++-- .../GanchoAITests/SensitiveDataDetectorTests.swift | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/Packages/GanchoKit/Sources/GanchoAI/SensitiveDataDetector.swift b/Packages/GanchoKit/Sources/GanchoAI/SensitiveDataDetector.swift index 226c55a5..94c459b5 100644 --- a/Packages/GanchoKit/Sources/GanchoAI/SensitiveDataDetector.swift +++ b/Packages/GanchoKit/Sources/GanchoAI/SensitiveDataDetector.swift @@ -112,8 +112,8 @@ public struct SensitiveDataDetector: Sendable { private func containsValidCard(_ text: String) -> Bool { var searchRange = text.startIndex.. Date: Tue, 6 Oct 2026 15:39:38 +0000 Subject: [PATCH 3/4] fix(privacy): try grouped card spans inside longer digit runs A card followed by its CVV, or preceded by a phone number or short prefix, joined the neighbouring digits into one 19-digit candidate that failed Luhn, so the card was never checked on its own. Match whole digit runs, split them on single spaces or dashes, and run Luhn on every contiguous span of 13-19 digits. Multi-group spans only count when every group is 3-6 digits, the shapes cards are printed in, so number lists, dates and order segments do not turn into extra Luhn draws. --- CHANGELOG.md | 4 ++- .../GanchoAI/SensitiveDataDetector.swift | 34 ++++++++++++++++--- .../SensitiveDataDetectorTests.swift | 34 +++++++++++++++++++ 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b42f75a..3c00e006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,9 @@ and release versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Detect a credit card even when an earlier order or reference number fails - its checksum, so mixed clipboard text still gets masking and short expiry. + its checksum, or when the card is followed by its security code or preceded + by a phone number, so mixed clipboard text still gets masking and short + expiry. Phone numbers, dates and order ids keep their normal handling. - Keep the panel's hidden ⌘V, ⌥⌘V, ⌘Y, ⌘G and ⇧⌘C shortcuts away from a field being edited, the board picker and the shortcuts sheet, and stop list keys diff --git a/Packages/GanchoKit/Sources/GanchoAI/SensitiveDataDetector.swift b/Packages/GanchoKit/Sources/GanchoAI/SensitiveDataDetector.swift index 94c459b5..e7dc186f 100644 --- a/Packages/GanchoKit/Sources/GanchoAI/SensitiveDataDetector.swift +++ b/Packages/GanchoKit/Sources/GanchoAI/SensitiveDataDetector.swift @@ -107,21 +107,45 @@ public struct SensitiveDataDetector: Sendable { return matches(#"(?i)\bbearer\s+[A-Za-z0-9\-._~+/]{20,}=*"#, in: text) } - /// Checks every 13–19 digit run (spaces/dashes allowed). An order number - /// that fails Luhn must not hide a later card in the same clipboard text. + /// Checks every digit run (single spaces/dashes allowed between digits). + /// An order number that fails Luhn must not hide a later card, and a card + /// glued to a CVV or a phone prefix must not hide inside one longer run. private func containsValidCard(_ text: String) -> Bool { var searchRange = text.startIndex.. Bool { + let groups = run.split(whereSeparator: { $0 == " " || $0 == "-" }) + for start in groups.indices { + var digits = "" + for end in start.. start, + !groups[start...end].allSatisfy({ (3...6).contains($0.count) }) + { + break + } + digits += groups[end] + if digits.count > 19 { break } + if digits.count >= 13, Luhn.validates(digits) { return true } + } + } + return false + } + /// Three routes, all conservative: /// 1. Context: "password:"/"pwd ="/"contraseña:" followed by a token. /// 2. Shape: a single 12–64 char token using all four character classes diff --git a/Packages/GanchoKit/Tests/GanchoAITests/SensitiveDataDetectorTests.swift b/Packages/GanchoKit/Tests/GanchoAITests/SensitiveDataDetectorTests.swift index 19ee3f73..32f257f6 100644 --- a/Packages/GanchoKit/Tests/GanchoAITests/SensitiveDataDetectorTests.swift +++ b/Packages/GanchoKit/Tests/GanchoAITests/SensitiveDataDetectorTests.swift @@ -152,6 +152,40 @@ struct SensitiveDataDetectorTests { #expect(detector.detect(input) == .creditCard) } + @Test( + "A card glued to a CVV, a short prefix or a phone number is still found", + arguments: [ + "4111 1111 1111 1111 123", + "4111111111111111 123", + "5555-5555-5555-4444 737", + "Tel 555 1234 4111 1111 1111 1111", + "acct 12 4111 1111 1111 1111", + "+1 555 123 4567 4111 1111 1111 1111", + "3782 822463 10005 1234" + ]) + func cardInsideLongerRun(input: String) { + #expect(detector.detect(input) == .creditCard, "input: \(input)") + } + + @Test( + "Phone numbers, order ids, dates and number lists stay clean", + arguments: [ + "+1 (555) 123-4567", + "555-123-4567", + "+44 20 7946 0958", + "+1 415 555 0132 ext 4410", + "Call 1-800-555-0199 or 1-800-555-0123", + "Order #2024-0001-2345-6789", + "Order ID 112-4589631-7745210", + "Invoice 2026 0042 7781 1290 shipped", + "PO 4500012345 line 0010 qty 120", + "2024-01-15 2024-02-15 2024-03-15", + "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20" + ]) + func groupedNonCardsStayClean(input: String) { + #expect(detector.detect(input) == nil, "input: \(input)") + } + @Test("Several invalid card-shaped numbers stay clean") func nonCardNumbersStayClean() { #expect( From e97157bb5df9562ce137ca5588f95b9f418741e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 03:48:57 +0000 Subject: [PATCH 4/4] fix(privacy): keep legacy card groupings and scan digit runs in one pass - Always try the longest leading 13-19 digit span of each run, so 8-8 and other unusual layouts that main already detected stay masked - Compile the digit-run pattern once and enumerate matches in a single pass; re-running String.range per run made large numeric pastes quadratic - Check only the newly added group when extending a span, and keep the span helper private - Cover unusual groupings with a regression test --- .../GanchoAI/SensitiveDataDetector.swift | 60 ++++++++++++------- .../SensitiveDataDetectorTests.swift | 12 ++++ 2 files changed, 51 insertions(+), 21 deletions(-) diff --git a/Packages/GanchoKit/Sources/GanchoAI/SensitiveDataDetector.swift b/Packages/GanchoKit/Sources/GanchoAI/SensitiveDataDetector.swift index e7dc186f..bcc248d9 100644 --- a/Packages/GanchoKit/Sources/GanchoAI/SensitiveDataDetector.swift +++ b/Packages/GanchoKit/Sources/GanchoAI/SensitiveDataDetector.swift @@ -107,35 +107,53 @@ public struct SensitiveDataDetector: Sendable { return matches(#"(?i)\bbearer\s+[A-Za-z0-9\-._~+/]{20,}=*"#, in: text) } - /// Checks every digit run (single spaces/dashes allowed between digits). - /// An order number that fails Luhn must not hide a later card, and a card - /// glued to a CVV or a phone prefix must not hide inside one longer run. + /// Digit runs with single spaces/dashes allowed between digits. Compiled + /// once: the detector runs on every capture, and a large numeric paste can + /// hold thousands of runs. + private static let digitRunPattern: NSRegularExpression = { + // swiftlint:disable:next force_try + try! NSRegularExpression(pattern: #"(? Bool { - var searchRange = text.startIndex.. Bool { + /// Tries the run's longest leading span of 13–19 digits whatever its + /// grouping (so 8-8 or digit-by-digit layouts stay covered), then every + /// contiguous span of separator-delimited groups whose digits total 13–19. + /// A single group is a bare card number; a multi-group span only counts + /// when every group is 3–6 digits, the shapes card numbers are printed in + /// (4-4-4-4, 4-6-5, 4-4-4-4-3). That keeps a space-separated list of small + /// numbers, a date or a 7-digit order segment from turning into many extra + /// Luhn draws. + private static func containsCardSpan(_ run: Substring) -> Bool { let groups = run.split(whereSeparator: { $0 == " " || $0 == "-" }) + var leading = "" + for group in groups { + guard leading.count + group.count <= 19 else { break } + leading += group + } + if leading.count >= 13, Luhn.validates(leading) { return true } + + let cardShaped = { (group: Substring) in (3...6).contains(group.count) } for start in groups.indices { var digits = "" for end in start.. start, - !groups[start...end].allSatisfy({ (3...6).contains($0.count) }) - { + if end > start, !cardShaped(groups[end]) || !cardShaped(groups[start]) { break } digits += groups[end] diff --git a/Packages/GanchoKit/Tests/GanchoAITests/SensitiveDataDetectorTests.swift b/Packages/GanchoKit/Tests/GanchoAITests/SensitiveDataDetectorTests.swift index 32f257f6..d8939dc8 100644 --- a/Packages/GanchoKit/Tests/GanchoAITests/SensitiveDataDetectorTests.swift +++ b/Packages/GanchoKit/Tests/GanchoAITests/SensitiveDataDetectorTests.swift @@ -167,6 +167,18 @@ struct SensitiveDataDetectorTests { #expect(detector.detect(input) == .creditCard, "input: \(input)") } + @Test( + "A card printed in an unusual grouping is still found", + arguments: [ + "41111111 11111111", + "4111 11111111 1111", + "4 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1", + "41111111 11111111 0000 0000" + ]) + func cardInUnusualGrouping(input: String) { + #expect(detector.detect(input) == .creditCard, "input: \(input)") + } + @Test( "Phone numbers, order ids, dates and number lists stay clean", arguments: [