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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ 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, 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
acting behind that sheet. Escape cancels a title or content edit.
Expand Down
66 changes: 56 additions & 10 deletions Packages/GanchoKit/Sources/GanchoAI/SensitiveDataDetector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -107,15 +107,61 @@ 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(
of: #"(?<![0-9])(?:[0-9][ -]?){12,18}[0-9](?![0-9])"#,
options: .regularExpression)
else { return nil }
let digits = text[range].filter(\.isNumber)
return (13...19).contains(digits.count) ? String(digits) : nil
/// 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: #"(?<![0-9])[0-9](?:[ -]?[0-9])*(?![0-9])"#)
}()

/// Checks every digit run. 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 found = false
Self.digitRunPattern.enumerateMatches(
in: text, range: NSRange(text.startIndex..., in: text)
) { match, _, stop in
guard let match, let range = Range(match.range, in: text) else { return }
if Self.containsCardSpan(text[range]) {
found = true
stop.pointee = true
}
}
return found
}

/// 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..<groups.endIndex {
if end > start, !cardShaped(groups[end]) || !cardShaped(groups[start]) {
break
}
digits += groups[end]
if digits.count > 19 { break }
if digits.count >= 13, Luhn.validates(digits) { return true }
}
}
return false
}

/// Three routes, all conservative:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,69 @@ struct SensitiveDataDetectorTests {
func negatives(input: String) {
#expect(detector.detect(input) == nil, "input: \(input)")
}

@Test(
"A non-card number cannot hide a later card",
arguments: [
"order 1234 5678 9012 3456; card 4111 1111 1111 1111",
"order 1234567890123456\ncard 378282246310005",
"📦 1234-5678-9012-3456; invalid 4111 1111 1111 1112; card 5555-5555-5555-4444"
])
func cardAfterNonCard(input: String) {
#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(
"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: [
"+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(
detector.detect("order 1234 5678 9012 3456; reference 4111 1111 1111 1112") == nil)
}
}

@Suite("Sensitive masking + ingestion decoration")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ struct ClipItemFactoryTests {
private let classifier = RuleClassifier()
private let detector = SensitiveDataDetector()

@Test("A card after an order number receives sensitive capture policy")
func cardAfterOrderNumberIsSensitive() {
let raw = "order 1234 5678 9012 3456; card 4111 1111 1111 1111"
let (item, _) = make(PasteboardCapture(text: raw))

#expect(item.isSensitive)
#expect(item.expiresAt != nil)
#expect(!item.preview.contains("4111 1111 1111 1111"))
#expect(item.preview.hasPrefix("●●●●"))
}

private func make(
_ capture: PasteboardCapture, detectSecrets: Bool = true,
sourceDeviceName: String? = nil
Expand Down
Loading