Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
141 changes: 141 additions & 0 deletions src/iban-countries.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, number | readonly number[]>> = {
// 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;
};
37 changes: 37 additions & 0 deletions src/value-detection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
6 changes: 4 additions & 2 deletions src/value-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
Loading