@@ -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 */
44179export 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