Find credit card numbers followed by an expiry, year or CVV (#2969)

* Find credit card numbers followed by an expiry, year or CVV

Found while testing crates/redact/src/patterns.rs:180-190 (credit_card, via digit_run at :53)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Redact: a card followed by a number drops at most four trailing digits

A longer grouped number (an IBAN's digits) no longer matches on a Luhn-valid prefix; the run is
read far enough to see the tail and refused when it goes on past that.

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
NetterSilver
2026-10-18 09:49:13 -04:00
committed by GitHub
co-authored by Claude Haiku 5.5 Brandon Thomas
parent 25495b959b
commit 2eb1639097
+37 -4
View File
@@ -183,12 +183,33 @@ fn credit_card(s: &[char], i: usize) -> Option<usize> {
if !s[i].is_ascii_digit() || digit(i.checked_sub(1).and_then(|p| s.get(p))) {
return None;
}
let (end, n) = digit_run(s, i, &[' ', '-'], 23);
if !(13..=19).contains(&n) || digit(s.get(end)) {
// Room for the longest card (19 digits in groups, 23 characters) and a tail of up to four digits.
let (end, n) = digit_run(s, i, &[' ', '-'], 29);
// A run longer than that is some other, longer number.
if end == i || run_continues(s, end - 1, 1, &[' ', '-']) {
return None;
}
let digits: Vec<u32> = s[i..end].iter().filter_map(|c| c.to_digit(10)).collect();
luhn(&digits).then_some(end - i)
// The longest run may carry a trailing number (an expiry, a year, a CVV), so try it and then
// each shorter run ending at a group boundary, longest first. The dropped tail is at most four
// digits, so a longer number (an IBAN's digits) doesn't match on a prefix.
let mut count = n;
for e in (i + 1..=end).rev() {
if n - count > 4 {
break;
}
if !s.get(e - 1).is_some_and(char::is_ascii_digit) {
continue;
}
let boundary = if e == end { !digit(s.get(end)) } else { matches!(s.get(e), Some(' ' | '-')) };
if boundary && (13..=19).contains(&count) {
let digits: Vec<u32> = s[i..e].iter().filter_map(|c| c.to_digit(10)).collect();
if luhn(&digits) {
return Some(e - i);
}
}
count = count.saturating_sub(1);
}
None
}
fn ssn(s: &[char], i: usize) -> Option<usize> {
@@ -435,6 +456,18 @@ mod tests {
);
}
#[test]
fn credit_card_followed_by_another_number() {
for tail in ["12/26", "2026", "123", "12 26"] {
assert_eq!(found(Pattern::CreditCard, &format!("Card 4111 1111 1111 1111 {tail}")), ["4111 1111 1111 1111"], "tail {tail}");
}
assert_eq!(found(Pattern::CreditCard, "Card 3782-822463-10005 123"), ["3782-822463-10005"]);
// A run that is not a card at any group boundary still matches nothing.
assert_eq!(found(Pattern::CreditCard, "Card 4111 1111 1111 1112 12/26"), Vec::<String>::new());
// A longer grouped number doesn't match on a card-shaped prefix.
assert_eq!(found(Pattern::CreditCard, "IBAN 4111 1111 1111 1111 0000 0000"), Vec::<String>::new());
}
#[test]
fn social_security_numbers() {
assert_eq!(