diff --git a/README.md b/README.md index 6c271cc..aba6a79 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ Being upfront about what this package does **not** do is part of its job: 2. **Free text is kept by default, but identifiers in it are scrubbed.** Unclassified free-text fields are not fully masked in schema-aware mode (masking them all would destroy utility), but a catch-all pass scrubs identifiers embedded in kept string values (custom fields, - computed titles, attribute types not special-cased): emails, phones, IBANs (mod-97 checked), + computed titles, attribute types not special-cased): emails, phones, IBANs (country code, length and mod-97 checked), German street addresses ("Hauptstraße 5", "Schiltacher Str. 33" — postal code and city stay), and PII inside URLs (components are decoded first; map links lose the place name, place ids and the precise position, coordinates are rounded to 2 decimals ≈ 1 km). Only the matched diff --git a/src/iban-countries.ts b/src/iban-countries.ts new file mode 100644 index 0000000..ac16aad --- /dev/null +++ b/src/iban-countries.ts @@ -0,0 +1,141 @@ +/** + * Compact IBAN length per country (ISO 13616, no spaces). Countries of the SWIFT IBAN registry, + * then countries that issue IBANs outside it (two lengths where the sources disagree). + * Sources: ibantools 4.5.4 `countrySpecs`, iban 0.0.14 (via @epilot/validators); both agree on + * every registry length. + */ +export const IBAN_LENGTHS: Readonly> = { + // SWIFT IBAN registry + AD: 24, + AE: 23, + AL: 28, + AT: 20, + AX: 18, + AZ: 28, + BA: 20, + BE: 16, + BG: 22, + BH: 22, + BR: 29, + BY: 28, + CH: 21, + CR: 22, + CY: 28, + CZ: 24, + DE: 22, + DK: 18, + DO: 28, + EE: 20, + EG: 29, + ES: 24, + FI: 18, + FO: 18, + FR: 27, + GB: 22, + GE: 22, + GF: 27, + GI: 23, + GL: 18, + GP: 27, + GR: 27, + GT: 28, + HR: 21, + HU: 28, + IE: 22, + IL: 23, + IQ: 23, + IS: 26, + IT: 27, + JO: 30, + KW: 30, + KZ: 20, + LB: 28, + LC: 32, + LI: 21, + LT: 20, + LU: 20, + LV: 21, + LY: 25, + MC: 27, + MD: 24, + ME: 22, + MF: 27, + MK: 19, + MN: 20, + MQ: 27, + MR: 27, + MT: 31, + MU: 30, + NC: 27, + NI: 28, + NL: 18, + NO: 15, + OM: 23, + PF: 27, + PK: 24, + PL: 28, + PM: 27, + PS: 29, + PT: 25, + QA: 29, + RE: 27, + RO: 24, + RS: 22, + RU: 33, + SA: 24, + SC: 31, + SD: 18, + SE: 24, + SI: 19, + SK: 24, + SM: 27, + SO: 23, + ST: 25, + SV: 28, + TF: 27, + TL: 23, + TN: 24, + TR: 26, + UA: 29, + VA: 22, + VG: 24, + WF: 27, + XK: 20, + YE: 30, + YT: 27, + // Outside the registry + AO: 25, + BF: [27, 28], + BI: [16, 27], + BJ: 28, + BL: 27, + CF: 27, + CG: 27, + CI: 28, + CM: 27, + CV: 25, + DJ: 27, + DZ: [24, 26], + FK: 18, + GA: 27, + GQ: 27, + GW: 25, + HN: 28, + IR: 26, + KM: 27, + MA: 28, + MG: 27, + ML: 28, + MZ: 25, + NE: 28, + SN: 28, + TD: 27, + TG: 28, +}; + +/** Whether a compact IBAN has a known country code and that country's length. */ +export const hasIbanCountryLength = (compact: string): boolean => { + const length = IBAN_LENGTHS[compact.slice(0, 2)]; + + return typeof length === 'number' ? length === compact.length : length?.includes(compact.length) === true; +}; diff --git a/src/value-detection.test.ts b/src/value-detection.test.ts index 6c4522a..a64fdc6 100644 --- a/src/value-detection.test.ts +++ b/src/value-detection.test.ts @@ -142,6 +142,43 @@ describe('IBAN checksum', () => { expect(isValidIban('DE89370400440532013001')).toBe(false); }); + // Puts valid check digits on a country code and BBAN, so only country and length decide. + const withCheckDigits = (country: string, bban: string) => { + const digits = `${bban}${country}00`.replace(/[A-Z]/g, (c) => String(c.charCodeAt(0) - 55)); + let remainder = 0; + for (const digit of digits) { + remainder = (remainder * 10 + Number(digit)) % 97; + } + + return `${country}${String(98 - remainder).padStart(2, '0')}${bban}`; + }; + + it('accepts IBANs of registry countries at their length, and of countries outside the registry', () => { + for (const iban of [ + 'CH9300762011623852957', + 'GB29NWBK60161331926819', + 'FR1420041010050500013M02606', + 'NO9386011117947', + 'LC55HEMM000100010012001200023015', + withCheckDigits('SN', 'A12345678901234567890123'), + ]) { + expect(isValidIban(iban)).toBe(true); + } + }); + + it('rejects an unknown country code or a wrong length, even with a valid checksum', () => { + expect(isValidIban(withCheckDigits('XX', '370400440532013000'))).toBe(false); + expect(isValidIban(withCheckDigits('DE', '37040044053201300'))).toBe(false); + expect(isValidIban(withCheckDigits('DE', '3704004405320130000'))).toBe(false); + }); + + it('does not mask hex ids that look like IBANs', () => { + // 32 hex characters starting with two letters and two digits; no IBAN country has length 32. + const hexId = withCheckDigits('CF', '68BD89442894AD4BBA4D00225817'); + expect(isValidIban(hexId)).toBe(false); + expect(anonymizer.scrub(`EXIT-LOOP-${hexId}`)).toBe(`EXIT-LOOP-${hexId}`); + }); + it('masks a valid IBAN followed by uppercase text, but not IBAN-shaped codes', () => { const result = anonymizer.scrub('DE89 3704 0044 0532 0130 00 BIC COBADEFFXXX') as string; diff --git a/src/value-detection.ts b/src/value-detection.ts index 18845f3..7e111cc 100644 --- a/src/value-detection.ts +++ b/src/value-detection.ts @@ -6,6 +6,8 @@ * `link_gebaeudeansicht` attribute holding a Google Maps link to the customer's house. */ +import { hasIbanCountryLength } from './iban-countries'; + /** Replacers for detected spans (the pseudonymizer's own, or the redactor without a secret) */ export type ValueReplacers = { street: (value: string) => string; @@ -53,9 +55,9 @@ export const scrubStreets = (value: string, replacers: ValueReplacers): string = // --- IBAN checksum ----------------------------------------------------------------------- -/** ISO 13616 mod-97 check of a compact IBAN */ +/** ISO 13616 check of a compact IBAN: known country code, that country's length, mod-97 checksum */ export const isValidIban = (compact: string): boolean => { - if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(compact)) { + if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(compact) || !hasIbanCountryLength(compact)) { return false; }