From dfa1ed39b01b43a9323f0eee62707accb3472575 Mon Sep 17 00:00:00 2001 From: Pavish Kumar Ramani Gopal Date: Thu, 5 Nov 2026 11:17:46 +0000 Subject: [PATCH] feat: check IBAN country code and length isValidIban accepted any 15 to 34 character candidate with a valid mod-97 checksum. A random candidate passes the checksum 1 time in 97, so hex ids and other codes that start with two letters and two digits were still taken for IBANs now and then. A candidate must now also start with a country code that issues IBANs and have that country's length (compact form, spaces removed). The table has the 97 countries of the SWIFT IBAN registry and 27 countries that issue IBANs outside it, taken from ibantools 4.5.4 and iban 0.0.14; where the two disagree on a non-registry country, both lengths are accepted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E3AAifuwHuKGdoVpqZoQAo --- README.md | 2 +- src/iban-countries.ts | 141 ++++++++++++++++++++++++++++++++++++ src/value-detection.test.ts | 37 ++++++++++ src/value-detection.ts | 6 +- 4 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 src/iban-countries.ts 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; }