Skip to content

Commit 6a094b9

Browse files
authored
Merge pull request #211 from dahlia/bugfix/credit-card-number-pattern
Redact credit card numbers in common formats
2 parents 471aaca + 56e638a commit 6a094b9

4 files changed

Lines changed: 224 additions & 24 deletions

File tree

CHANGES.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ Version 2.0.19
88

99
To be released.
1010

11+
### @logtape/redaction
12+
13+
- Fixed `CREDIT_CARD_NUMBER_PATTERN` to redact Luhn-valid credit card numbers
14+
with 13–19 digits, including unseparated numbers and common space- and
15+
hyphen-separated formats. Numbers that fit these formats but fail the
16+
Luhn check are no longer redacted. [[#210], [#211]]
17+
18+
[#210]: https://github.com/dahlia/logtape/issues/210
19+
[#211]: https://github.com/dahlia/logtape/pull/211
20+
1121

1222
Version 2.0.18
1323
--------------

docs/manual/redaction.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ import {
100100
~~~~
101101

102102
- `EMAIL_ADDRESS_PATTERN`: Redacts email addresses
103-
- `CREDIT_CARD_NUMBER_PATTERN`: Redacts credit card numbers
103+
- `CREDIT_CARD_NUMBER_PATTERN`: Redacts Luhn-valid credit card numbers with
104+
13–19 digits, including common space- and hyphen-separated formats. Numbers
105+
that fit these formats but fail the Luhn check are left unchanged
104106
- `JWT_PATTERN`: Redacts JSON Web Tokens
105107
- `US_SSN_PATTERN`: Redacts U.S. Social Security numbers
106108
- `KR_RRN_PATTERN`: Redacts South Korean resident registration numbers

packages/redaction/src/pattern.test.ts

Lines changed: 73 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -69,28 +69,81 @@ test("EMAIL_ADDRESS_PATTERN", () => {
6969
test("CREDIT_CARD_NUMBER_PATTERN", () => {
7070
const { pattern, replacement } = CREDIT_CARD_NUMBER_PATTERN;
7171

72-
// Test valid credit card numbers with dashes
73-
assert.match("1234-5678-9012-3456", pattern); // Regular 16-digit card
74-
pattern.lastIndex = 0;
75-
assert.match("1234-5678-901234", pattern); // American Express format
76-
pattern.lastIndex = 0;
72+
const redact = (value: string): string =>
73+
typeof replacement === "string"
74+
? value.replaceAll(pattern, replacement)
75+
: value.replaceAll(pattern, replacement);
76+
77+
const validNumbers = [
78+
"4222222222222",
79+
"4222 2222 2222 2",
80+
"30569309025904",
81+
"3056 9309 025904",
82+
"3056-9309-025904",
83+
"3056-930902-5904",
84+
"1354 12345 678911",
85+
"1354-12345-678911",
86+
"378282246310005",
87+
"3782 822463 10005",
88+
"3782-822463-10005",
89+
"4111111111111111",
90+
"4111 1111 1111 1111",
91+
"4111 1111 1111 1111",
92+
"4111-1111-1111-1111",
93+
"5500005555555559",
94+
"4000000000000000006",
95+
"4000 0000 0000 0000 006",
96+
"4000-0000-0000-0000-006",
97+
];
98+
99+
for (const number of validNumbers) {
100+
assert.strictEqual(
101+
redact(`Card: ${number}`),
102+
"Card: XXXX-XXXX-XXXX-XXXX",
103+
);
104+
}
77105

78-
// Test replacements
79106
assert.strictEqual(
80-
"Card: 1234-5678-9012-3456".replaceAll(pattern, replacement as string),
81-
"Card: XXXX-XXXX-XXXX-XXXX",
107+
redact("Cards: 4111111111111111 and 3782-822463-10005"),
108+
"Cards: XXXX-XXXX-XXXX-XXXX and XXXX-XXXX-XXXX-XXXX",
82109
);
83110
assert.strictEqual(
84-
"AmEx: 1234-5678-901234".replaceAll(pattern, replacement as string),
85-
"AmEx: XXXX-XXXX-XXXX-XXXX",
111+
redact("Payment: 4111-1111-1111-1111-12-28"),
112+
"Payment: XXXX-XXXX-XXXX-XXXX-12-28",
86113
);
87114
assert.strictEqual(
88-
"Cards: 1234-5678-9012-3456 and 1234-5678-901234".replaceAll(
89-
pattern,
90-
replacement as string,
91-
),
92-
"Cards: XXXX-XXXX-XXXX-XXXX and XXXX-XXXX-XXXX-XXXX",
115+
redact("Payment: 4111 1111 1111 1111 12/28"),
116+
"Payment: XXXX-XXXX-XXXX-XXXX 12/28",
93117
);
118+
assert.strictEqual(
119+
redact("Order 0001 4111-1111-1111-1111"),
120+
"Order 0001 XXXX-XXXX-XXXX-XXXX",
121+
);
122+
assert.strictEqual(
123+
redact("card_4111-1111-1111-1111_token"),
124+
"card_XXXX-XXXX-XXXX-XXXX_token",
125+
);
126+
assert.strictEqual(
127+
redact("Ref 1234 5678 4111 1111 1111 1111"),
128+
"Ref 1234 5678 XXXX-XXXX-XXXX-XXXX",
129+
);
130+
assert.strictEqual(
131+
redact("Cards: 4111 1111 1111 1111 5500 0055 5555 5559"),
132+
"Cards: XXXX-XXXX-XXXX-XXXX XXXX-XXXX-XXXX-XXXX",
133+
);
134+
135+
const invalidNumbers = [
136+
"123456789012",
137+
"12345678901234",
138+
"1234-5678-901234",
139+
"4111111111111112",
140+
"4111 1111 1111 1112",
141+
"12345678901234567890",
142+
];
143+
144+
for (const number of invalidNumbers) {
145+
assert.strictEqual(redact(`Number: ${number}`), `Number: ${number}`);
146+
}
94147
});
95148

96149
test("US_SSN_PATTERN", () => {
@@ -170,10 +223,10 @@ test("redactByPattern(TextFormatter)", () => {
170223
level: "info",
171224
category: ["test"],
172225
message: [
173-
"Sensitive info: email = user@example.com, cc = 1234-5678-9012-3456, ssn = 123-45-6789",
226+
"Sensitive info: email = user@example.com, cc = 4111-1111-1111-1111, ssn = 123-45-6789",
174227
],
175228
rawMessage:
176-
"Sensitive info: email = user@example.com, cc = 1234-5678-9012-3456, ssn = 123-45-6789",
229+
"Sensitive info: email = user@example.com, cc = 4111-1111-1111-1111, ssn = 123-45-6789",
177230
timestamp: Date.now(),
178231
properties: {},
179232
};
@@ -258,7 +311,7 @@ test("redactByPattern(ConsoleFormatter)", () => {
258311
{
259312
name: "John Doe",
260313
email: "john@example.com",
261-
creditCard: "1234-5678-9012-3456",
314+
creditCard: "4111-1111-1111-1111",
262315
},
263316
],
264317
rawMessage: "User data: [object Object]",
@@ -306,8 +359,8 @@ test("redactByPattern(ConsoleFormatter)", () => {
306359
},
307360
payment: {
308361
cards: [
309-
"1234-5678-9012-3456",
310-
"8765-4321-8765-4321",
362+
"4111-1111-1111-1111",
363+
"5500-0055-5555-5559",
311364
],
312365
},
313366
documents: {

packages/redaction/src/pattern.ts

Lines changed: 138 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,148 @@ export const EMAIL_ADDRESS_PATTERN: RedactionPattern = {
3737
replacement: "REDACTED@EMAIL.ADDRESS",
3838
};
3939

40+
function hasValidLuhnChecksum(digits: string): boolean {
41+
let checksum = 0;
42+
let shouldDouble = false;
43+
44+
for (let i = digits.length - 1; i >= 0; i--, shouldDouble = !shouldDouble) {
45+
let digit = digits.charCodeAt(i) - 48;
46+
if (shouldDouble) {
47+
digit *= 2;
48+
if (digit > 9) digit -= 9;
49+
}
50+
checksum += digit;
51+
}
52+
53+
return checksum % 10 === 0;
54+
}
55+
56+
function hasCommonCreditCardGrouping(groups: readonly string[]): boolean {
57+
if (groups.length === 1) {
58+
return groups[0].length >= 13 && groups[0].length <= 19;
59+
}
60+
if (groups.length === 3) {
61+
return groups[0].length === 4 &&
62+
((groups[1].length === 6 && groups[2].length >= 4 &&
63+
groups[2].length <= 5) ||
64+
((groups[1].length === 4 || groups[1].length === 5) &&
65+
groups[2].length === 6));
66+
}
67+
if (groups.length === 4) {
68+
return groups[0].length === 4 && groups[1].length === 4 &&
69+
groups[2].length === 4 && groups[3].length >= 1 &&
70+
groups[3].length <= 4;
71+
}
72+
if (groups.length === 5) {
73+
return groups[0].length === 4 && groups[1].length === 4 &&
74+
groups[2].length === 4 && groups[3].length === 4 &&
75+
groups[4].length >= 1 && groups[4].length <= 3;
76+
}
77+
return false;
78+
}
79+
80+
const creditCardNumberReplacement = "XXXX-XXXX-XXXX-XXXX";
81+
82+
type CreditCardCandidate = {
83+
start: number;
84+
end: number;
85+
endGroup: number;
86+
};
87+
88+
type CreditCardCover = {
89+
readonly candidate: CreditCardCandidate;
90+
readonly next: CreditCardCover | null;
91+
};
92+
93+
function redactCreditCardNumber(match: string): string {
94+
const groups = [...match.matchAll(/\d+/g)];
95+
const candidates: CreditCardCandidate[] = [];
96+
const candidatesByStart: CreditCardCandidate[][] = Array.from(
97+
{ length: groups.length },
98+
() => [],
99+
);
100+
101+
for (let start = 0; start < groups.length; start++) {
102+
let digits = "";
103+
for (let end = start; end < groups.length; end++) {
104+
digits += groups[end][0];
105+
if (digits.length > 19) break;
106+
if (
107+
digits.length >= 13 &&
108+
hasCommonCreditCardGrouping(
109+
groups.slice(start, end + 1).map((group) => group[0]),
110+
) &&
111+
hasValidLuhnChecksum(digits)
112+
) {
113+
const candidate = {
114+
start: groups[start].index,
115+
end: groups[end].index + groups[end][0].length,
116+
endGroup: end,
117+
};
118+
candidates.push(candidate);
119+
candidatesByStart[start].push(candidate);
120+
}
121+
}
122+
}
123+
124+
if (candidates.length === 0) return match;
125+
126+
// Keep adjacent card numbers as separate redactions when their candidates
127+
// cover the complete group sequence without overlapping.
128+
const completeCovers: (CreditCardCover | null | undefined)[] = [];
129+
completeCovers[groups.length] = null;
130+
for (let start = groups.length - 1; start >= 0; start--) {
131+
for (const candidate of candidatesByStart[start]) {
132+
const tail = completeCovers[candidate.endGroup + 1];
133+
if (tail !== undefined) {
134+
completeCovers[start] = { candidate, next: tail };
135+
break;
136+
}
137+
}
138+
}
139+
140+
const completeCover = completeCovers[0];
141+
const intervals = completeCover === undefined ? candidates : [];
142+
for (
143+
let cover = completeCover;
144+
cover != null;
145+
cover = cover.next
146+
) {
147+
intervals.push(cover.candidate);
148+
}
149+
const redacted: string[] = [];
150+
let candidate = { ...intervals[0] };
151+
let offset = 0;
152+
for (let i = 1; i < intervals.length; i++) {
153+
const next = intervals[i];
154+
// Merge ambiguous overlaps so no portion of a possible PAN remains visible.
155+
if (next.start < candidate.end) {
156+
candidate.end = Math.max(candidate.end, next.end);
157+
} else {
158+
redacted.push(
159+
match.slice(offset, candidate.start),
160+
creditCardNumberReplacement,
161+
);
162+
offset = candidate.end;
163+
candidate = next;
164+
}
165+
}
166+
redacted.push(
167+
match.slice(offset, candidate.start),
168+
creditCardNumberReplacement,
169+
match.slice(candidate.end),
170+
);
171+
return redacted.join("");
172+
}
173+
40174
/**
41-
* A redaction pattern for credit card numbers (including American Express).
175+
* A redaction pattern for Luhn-valid credit card numbers with 13–19 digits,
176+
* including numbers separated into common groups with spaces or hyphens.
42177
* @since 0.10.0
43178
*/
44179
export const CREDIT_CARD_NUMBER_PATTERN: RedactionPattern = {
45-
pattern: /(?:\d{4}-){2}(?:\d{4}-\d{4}|\d{6})/g,
46-
replacement: "XXXX-XXXX-XXXX-XXXX",
180+
pattern: /(?<!\d)(?:\d{13,19}|\d{4}(?:(?: +|-)\d{1,6})+)(?!\d)/g,
181+
replacement: redactCreditCardNumber,
47182
};
48183

49184
/**

0 commit comments

Comments
 (0)