euredact 0.3.5 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,15 +12,29 @@ function getContext(text, start, end) {
12
12
  }
13
13
  // Unicode-safe word boundary: negative lookahead for any letter (including accented)
14
14
  const _UWB = "(?![a-zA-Z\\u00C0-\\u024F\\u0400-\\u04FF])";
15
- const CURRENCY_AFTER = new RegExp(`^\\s*(?:EUR|€|\\$|USD|GBP|£|CHF|ISK|SEK|NOK|DKK|euro|euros|dollar|dollars|pond|kronor|kroner|kr)${_UWB}`, "i");
16
- const CURRENCY_COMMA_AFTER = new RegExp(`^[.,]\\d{1,2}\\s*(?:EUR|€|\\$|USD|GBP|£|CHF|ISK|SEK|NOK|DKK|euro|euros|kr(?:onor|oner)?|pond)${_UWB}`, "i");
17
- const CURRENCY_BEFORE = /(?:EUR|€|\$|USD|GBP|£|CHF|ISK|SEK|NOK|DKK)\s*$/;
18
- const AMOUNT_LABEL_BEFORE = /(?:Montant|Beløb|Summa|Summe|Bedrag|Amount|Total|TTC|inkl|Upphæð)\s*:?\s*$/i;
15
+ // Symbols are held apart from the alphabetic codes because a word boundary
16
+ // after "€" is meaningless — the symbol is not a word character. Python used a
17
+ // bare "\b" here and so could never match "1163 €," at all; this port used the
18
+ // lookahead below and did not have that defect. Kept split so the difference
19
+ // cannot come back.
20
+ const CURRENCY_SYMBOL = "€|\\$|£|¥|₺|zł|Kč|лв|kn|Ft|₽";
21
+ const CURRENCY_WORD = "EUR|USD|GBP|CHF|ISK|SEK|NOK|DKK|PLN|CZK|HUF|RON|BGN|HRK|" +
22
+ "euro|euros|dollar|dollars|pond|kronor|kroner|kronur|kr|" +
23
+ "złoty|korun|forint|lei|leva";
24
+ const CURRENCY_AFTER = new RegExp(`^\\s*(?:(?:${CURRENCY_SYMBOL})|(?:${CURRENCY_WORD})${_UWB})`, "i");
25
+ const CURRENCY_COMMA_AFTER = new RegExp(`^[.,]\\d{1,2}\\s*(?:(?:${CURRENCY_SYMBOL})|(?:${CURRENCY_WORD})${_UWB})`, "i");
26
+ const CURRENCY_BEFORE = new RegExp(`(?:(?:${CURRENCY_SYMBOL})|\\b(?:${CURRENCY_WORD}))\\s*$`, "i");
27
+ const AMOUNT_LABEL_BEFORE = /\b(?:Montant|Beløb|Summa|Summe|Bedrag|Amount|Total|TTC|inkl|Upphæð|Importe|Importo|Valore|Wartość|Kwota|Částka|Összeg|Sum|Beloop|Prix|Preis|Price|Loyer|Miete|Huur|Rent|Betrag)\s*(?:\w+\s*)?:?\s*$/i;
19
28
  // Use Unicode-aware word boundary via \p{L} negative lookahead instead of \b
20
29
  // because JS \b is ASCII-only and fails on Unicode letters (e.g. "München" → \bm\b matches M before ü)
21
30
  const UNIT_AFTER = /^\s*(?:kg|km|cm|mm|m[²³]|ml|mg|GB|MB|KB|TB|%|jaar|maanden|weken|dagen|uur|minuten|seconden|stuks|pcs|pieces|ans|mois|semaines|jours|heures|Jahre|Monate|Wochen|Tage|Stunden)(?![a-zA-Z\u00C0-\u024F\u0400-\u04FF])/i;
22
31
  // Same missing-boundary flaw: "ref" matched the tail of "kortref".
23
- const REFERENCE_BEFORE = /\b(?:dossier|ref\.?|referentie|reference|référence|factuurnummer|invoice\s*(?:nr|number|no)?|bestelnummer|order\s*(?:nr|number|no)?|kenmerk|ordernummer|Aktenzeichen|numéro\s*de\s*(?:dossier|facture|commande)|bestellnummer|Rechnungsnummer|artikelnr|article\s*no|contract\s*(?:nr|number|no)?|pagina|page|Seite|blz\.?|Facture\s*n[°o]?|Faktura\s*n[°or]\.?|Lasku\s*n[°or]o?\.?|Rechnung\s*(?:Nr|n[°o])?|faktura\s*(?:nr|n[°o])?|bestilling\s*(?:nr|n[°o])?|bestelling\s*n[°or]\.?|Reikningur\s*nr|Factuur\s*n[ro]?\.?|Nota\s*n[ro]?\.?)\s*[:.]?\s*$/i;
32
+ const REFERENCE_BEFORE = /\b(?:dossier|ref\.?|referentie|reference|référence|factuurnummer|invoice\s*(?:nr|number|no)?|bestelnummer|order\s*(?:nr|number|no)?|kenmerk|ordernummer|Aktenzeichen|numéro\s*de\s*(?:dossier|facture|commande)|bestellnummer|Rechnungsnummer|artikelnr|article\s*no|contract\s*(?:nr|number|no)?|pagina|page|Seite|blz\.?|Facture\s*n[°o]?|Faktura\s*n[°or]\.?|Lasku\s*n[°or]o?\.?|Rechnung\s*(?:Nr|n[°o])?|faktura\s*(?:nr|n[°o])?|bestilling\s*(?:nr|n[°o])?|bestelling\s*n[°or]\.?|Reikningur\s*nr|ticket|incident\s*(?:report|nr|no)?|case\s*(?:nr|no|id)?|zaaknummer|meldingsnummer|Vorgangsnummer|Vorgang|Störungsmeldung|saksnummer|ärendenummer|sagsnummer|asianumero|Factuur\s*n[ro]?\.?|Nota\s*n[ro]?\.?)\s*[:.#]?\s*$/i;
33
+ // "#" before a number marks a reference in every language in scope — a ticket,
34
+ // an order, a line item — and never a postcode or a national identifier.
35
+ const HASH_BEFORE = /#\s*$/;
36
+ // A short uppercase tag hyphenated to the number: "IR-43433", "INC-2024".
37
+ const REF_PREFIX_BEFORE = /(?:^|[\s([])[A-Z]{2,5}-$/;
24
38
  const LEGAL_BEFORE = /(?:Art(?:ikel|icle|\.)|§|Artikel|Section|Sectie|Afdeling|paragraaf|Absatz|alinéa|punt|point|Punkt|lid)\s*$/i;
25
39
  const MATH_BEFORE = /[=+\-×÷*/]\s*$/;
26
40
  const MATH_AFTER = /^\s*[=+\-×÷*/]/;
@@ -52,7 +66,11 @@ const MONTH_NAMES = "january|february|march|april|may|june|july|august|september
52
66
  "|gennaio|febbraio|marzo|aprile|maggio|giugno|luglio|agosto|settembre|ottobre|dicembre" +
53
67
  "|enero|febrero|abril|mayo|junio|julio|septiembre|octubre|noviembre|diciembre" +
54
68
  "|janeiro|fevereiro|março|marco|maio|junho|julho|setembro|outubro|novembro|dezembro" +
55
- "|marts|augusti" +
69
+ // "desember" (nb/nn/is) was the one December spelling missing from a list
70
+ // that already carried ten others — "1. desember 2025" was a Norwegian
71
+ // postcode because of it.
72
+ "|marts|augusti|desember" +
73
+ "|janúar|febrúar|apríl|maí|júní|júlí|ágúst|nóvember" +
56
74
  "|tammikuu|helmikuu|maaliskuu|huhtikuu|toukokuu|kesäkuu|heinäkuu|elokuu|syyskuu|lokakuu|marraskuu|joulukuu" +
57
75
  "|jaanuar|veebruar|aprill|juuni|juuli|oktoober|detsember" +
58
76
  "|janvāris|februāris|aprīlis|maijs|jūnijs|jūlijs|augusts|septembris|oktobris|novembris|decembris" +
@@ -77,7 +95,11 @@ const DATE_AFTER = /^[./-]\d{1,2}[./-]\d{1,2}\b/;
77
95
  * ISO 4217 codes made only of the consonants a Spanish plate accepts, so a money
78
96
  * amount reads as a registration. This is why "2297 DKK" was a plate.
79
97
  */
80
- const CURRENCY_CODE = "(?:DKK|SEK|NOK|ISK|CZK|PLN|HUF|RON|BGN|HRK|CHF|GBP|TRY|RSD|MKD|BYN|KZT|CNY|JPY|KRW|ZAR|BRL|MXN|CLP|COP|PLZ|SKK|TRL)";
98
+ // Crypto tickers are the same shape and the same mistake — every surviving
99
+ // plate false positive was "4499 BTC" or a sibling. A ticker is a unit, exactly
100
+ // as an ISO 4217 code is.
101
+ const CURRENCY_CODE = "(?:DKK|SEK|NOK|ISK|CZK|PLN|HUF|RON|BGN|HRK|CHF|GBP|TRY|RSD|MKD|BYN|KZT|CNY|JPY|KRW|ZAR|BRL|MXN|CLP|COP|PLZ|SKK|TRL" +
102
+ "|BTC|ETH|LTC|XRP|BCH|XLM|XMR|DOT|SOL|ADA|TRX|DOGE|USDT|USDC)";
81
103
  const CURRENCY_CODE_TAIL = new RegExp(`\\s${CURRENCY_CODE}$`);
82
104
  const CURRENCY_CODE_AFTER = new RegExp(`^\\s*${CURRENCY_CODE}\\b`);
83
105
  /** Log-timestamp tails, which the ":"-anchored secret rule picks up. */
@@ -91,6 +113,32 @@ const NOT_A_SECRET = new RegExp("^(?:af|ap|ca|cn|eu|il|me|sa|us|gov)-(?:north|so
91
113
  const EXACT_UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
92
114
  const BIC_SHAPE = /^[A-Z]{6}[A-Z0-9]{2}(?:[A-Z0-9]{3})?$/;
93
115
  const BIC_CUE_BEFORE = /(?:BIC|SWIFT|BIC\/SWIFT)\s*[:=]?\s*\(?\s*$/i;
116
+ /** An email address, exactly. Same argument as the UUID and the BIC. */
117
+ const EXACT_EMAIL = /^[\w.!#$%&'*+/=?^`{|}~-]+@[\w-]+(?:\.[\w-]+)+$/;
118
+ /**
119
+ * A URL, or the tail of one after the scheme has been split off. The secret
120
+ * rules anchor on ":" and "=", so "https://api.sendgrid.com/v3/mail/send" hands
121
+ * them "//api.sendgrid.com/v3/mail/send" and a slash-separated path scores as
122
+ * high-entropy. An endpoint is published documentation, not a credential.
123
+ */
124
+ const URL_LIKE = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^\s/]+(?:\/\S*)?$|^[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,}(?::\d{1,5})?(?:\/\S*)?$/i;
125
+ /**
126
+ * A URL that *carries* a credential is the opposite case, and it is the common
127
+ * one: "mongodb://admin:DFKDKi1eb51OOhuHPYz@rds-main.eu-west-1.rds.amazonaws.com"
128
+ * is a connection string with a live password in it. Suppressing those as "just
129
+ * a URL" lost 347 real secrets, so the endpoint test must not fire when userinfo
130
+ * or a credential-bearing query parameter is present.
131
+ */
132
+ const URL_WITH_CREDENTIALS = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^/@\s]*:[^/@\s]*@|[?&](?:api_?key|access_?token|auth|token|secret|password|pwd|sig|signature|credential)=/i;
133
+ /** An LDAP distinguished name: "cn=github-actions,dc=corp,dc=eu". */
134
+ const LDAP_DN = /^(?:cn|ou|dc|uid|o|l|st|c)=[^,=]+(?:,\s*(?:cn|ou|dc|uid|o|l|st|c)=[^,=]+)+$/i;
135
+ /**
136
+ * Lower-case words joined by hyphens, optionally with a short id suffix:
137
+ * "service-account", "data-lake-33e061". The single-word test below already
138
+ * rejects "sozialversicherungsnummer" on the reasoning that a real secret would
139
+ * be mixed case; a hyphenated compound is the same token with a separator.
140
+ */
141
+ const HYPHENATED_WORDS = /^[a-z]{2,}(?:-[a-z]{2,})+(?:-[0-9a-f]{4,8})?$/;
94
142
  /** A dotted quad with every octet in range. */
95
143
  const DOTTED_QUAD = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
96
144
  /** Two plausible years joined by a hyphen — a school year, a contract term. */
@@ -120,7 +168,7 @@ function suppressSecretOverStructured(text, match) {
120
168
  if (match.patternDef.entityType !== types_js_1.EntityType.SECRET)
121
169
  return false;
122
170
  const token = coreToken(match.text);
123
- if (EXACT_UUID.test(token))
171
+ if (EXACT_UUID.test(token) || EXACT_EMAIL.test(token))
124
172
  return true;
125
173
  if (BIC_SHAPE.test(token)) {
126
174
  return BIC_CUE_BEFORE.test(text.slice(Math.max(0, match.start - 24), match.start));
@@ -145,6 +193,12 @@ function suppressSecretNotASecret(_text, match) {
145
193
  return true;
146
194
  if (NOT_A_SECRET.test(bare))
147
195
  return true;
196
+ if (URL_LIKE.test(bare) && !URL_WITH_CREDENTIALS.test(bare))
197
+ return true;
198
+ if (LDAP_DN.test(bare))
199
+ return true;
200
+ if (HYPHENATED_WORDS.test(bare))
201
+ return true;
148
202
  const alpha = /^[A-Za-zÀ-ÿ]+$/.test(bare);
149
203
  const oneWord = bare === bare.toLowerCase() ||
150
204
  (bare.slice(0, 1) === bare.slice(0, 1).toUpperCase() &&
@@ -205,12 +259,27 @@ function suppressUnits(text, match) {
205
259
  const [, after] = getContext(text, match.start, match.end);
206
260
  return UNIT_AFTER.test(after);
207
261
  }
262
+ /**
263
+ * Suppress numbers preceded by reference/invoice/dossier keywords.
264
+ *
265
+ * POSTAL_CODE belongs here for the same reason every other numeric type does,
266
+ * and its absence was an oversight: a five-digit ticket number sits in exactly
267
+ * the shape a German or French postcode occupies, so "Ticket #94730" and
268
+ * "Incident report IR-43433" were masked as addresses.
269
+ *
270
+ * "#" and "XX-" are checked adjacently rather than through the 150-character
271
+ * keyword window. That window is what made the postal rule claim years in
272
+ * dates; widening its reach to fix a different symptom would repeat the error.
273
+ */
208
274
  function suppressReference(text, match) {
209
- const applicable = new Set([types_js_1.EntityType.PHONE, types_js_1.EntityType.NATIONAL_ID, types_js_1.EntityType.SSN, types_js_1.EntityType.TAX_ID, types_js_1.EntityType.IBAN, types_js_1.EntityType.CHAMBER_OF_COMMERCE]);
275
+ const applicable = new Set([types_js_1.EntityType.PHONE, types_js_1.EntityType.NATIONAL_ID, types_js_1.EntityType.SSN, types_js_1.EntityType.TAX_ID, types_js_1.EntityType.IBAN, types_js_1.EntityType.CHAMBER_OF_COMMERCE, types_js_1.EntityType.POSTAL_CODE]);
210
276
  if (!applicable.has(match.patternDef.entityType))
211
277
  return false;
212
278
  const [before] = getContext(text, match.start, match.end);
213
- return REFERENCE_BEFORE.test(before);
279
+ if (REFERENCE_BEFORE.test(before))
280
+ return true;
281
+ const adjacent = text.slice(Math.max(0, match.start - 8), match.start);
282
+ return HASH_BEFORE.test(adjacent) || REF_PREFIX_BEFORE.test(adjacent);
214
283
  }
215
284
  function suppressLegal(text, match) {
216
285
  const applicable = new Set([types_js_1.EntityType.PHONE, types_js_1.EntityType.NATIONAL_ID, types_js_1.EntityType.POSTAL_CODE]);
@@ -664,7 +733,7 @@ const TYPE_SUPPRESSORS = {
664
733
  [types_js_1.EntityType.NATIONAL_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressLegal, suppressMath, suppressNatidAsPassport, suppressSeNatidAsOrg],
665
734
  [types_js_1.EntityType.SSN]: [suppressCurrency, suppressUnits, suppressReference, suppressMath],
666
735
  [types_js_1.EntityType.TAX_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressTaxidAsIpAddress],
667
- [types_js_1.EntityType.POSTAL_CODE]: [suppressCurrency, suppressUnits, suppressMath, suppressLegal, suppressYearAsPostal, suppressPostalInsideIban, suppressPostalAsHouseNumber, suppressPostalInLongerIdentifier],
736
+ [types_js_1.EntityType.POSTAL_CODE]: [suppressCurrency, suppressUnits, suppressMath, suppressLegal, suppressYearAsPostal, suppressPostalInsideIban, suppressPostalAsHouseNumber, suppressPostalInLongerIdentifier, suppressReference],
668
737
  [types_js_1.EntityType.BIC]: [suppressBicWithoutEvidence],
669
738
  [types_js_1.EntityType.BANK_ACCOUNT]: [suppressReference],
670
739
  [types_js_1.EntityType.LICENSE_PLATE]: [suppressPlateInCompound, suppressDePlateUnknownDistrict, suppressPlateAsCurrencyAmount],
@@ -9,15 +9,29 @@ function getContext(text, start, end) {
9
9
  }
10
10
  // Unicode-safe word boundary: negative lookahead for any letter (including accented)
11
11
  const _UWB = "(?![a-zA-Z\\u00C0-\\u024F\\u0400-\\u04FF])";
12
- const CURRENCY_AFTER = new RegExp(`^\\s*(?:EUR|€|\\$|USD|GBP|£|CHF|ISK|SEK|NOK|DKK|euro|euros|dollar|dollars|pond|kronor|kroner|kr)${_UWB}`, "i");
13
- const CURRENCY_COMMA_AFTER = new RegExp(`^[.,]\\d{1,2}\\s*(?:EUR|€|\\$|USD|GBP|£|CHF|ISK|SEK|NOK|DKK|euro|euros|kr(?:onor|oner)?|pond)${_UWB}`, "i");
14
- const CURRENCY_BEFORE = /(?:EUR|€|\$|USD|GBP|£|CHF|ISK|SEK|NOK|DKK)\s*$/;
15
- const AMOUNT_LABEL_BEFORE = /(?:Montant|Beløb|Summa|Summe|Bedrag|Amount|Total|TTC|inkl|Upphæð)\s*:?\s*$/i;
12
+ // Symbols are held apart from the alphabetic codes because a word boundary
13
+ // after "€" is meaningless — the symbol is not a word character. Python used a
14
+ // bare "\b" here and so could never match "1163 €," at all; this port used the
15
+ // lookahead below and did not have that defect. Kept split so the difference
16
+ // cannot come back.
17
+ const CURRENCY_SYMBOL = "€|\\$|£|¥|₺|zł|Kč|лв|kn|Ft|₽";
18
+ const CURRENCY_WORD = "EUR|USD|GBP|CHF|ISK|SEK|NOK|DKK|PLN|CZK|HUF|RON|BGN|HRK|" +
19
+ "euro|euros|dollar|dollars|pond|kronor|kroner|kronur|kr|" +
20
+ "złoty|korun|forint|lei|leva";
21
+ const CURRENCY_AFTER = new RegExp(`^\\s*(?:(?:${CURRENCY_SYMBOL})|(?:${CURRENCY_WORD})${_UWB})`, "i");
22
+ const CURRENCY_COMMA_AFTER = new RegExp(`^[.,]\\d{1,2}\\s*(?:(?:${CURRENCY_SYMBOL})|(?:${CURRENCY_WORD})${_UWB})`, "i");
23
+ const CURRENCY_BEFORE = new RegExp(`(?:(?:${CURRENCY_SYMBOL})|\\b(?:${CURRENCY_WORD}))\\s*$`, "i");
24
+ const AMOUNT_LABEL_BEFORE = /\b(?:Montant|Beløb|Summa|Summe|Bedrag|Amount|Total|TTC|inkl|Upphæð|Importe|Importo|Valore|Wartość|Kwota|Částka|Összeg|Sum|Beloop|Prix|Preis|Price|Loyer|Miete|Huur|Rent|Betrag)\s*(?:\w+\s*)?:?\s*$/i;
16
25
  // Use Unicode-aware word boundary via \p{L} negative lookahead instead of \b
17
26
  // because JS \b is ASCII-only and fails on Unicode letters (e.g. "München" → \bm\b matches M before ü)
18
27
  const UNIT_AFTER = /^\s*(?:kg|km|cm|mm|m[²³]|ml|mg|GB|MB|KB|TB|%|jaar|maanden|weken|dagen|uur|minuten|seconden|stuks|pcs|pieces|ans|mois|semaines|jours|heures|Jahre|Monate|Wochen|Tage|Stunden)(?![a-zA-Z\u00C0-\u024F\u0400-\u04FF])/i;
19
28
  // Same missing-boundary flaw: "ref" matched the tail of "kortref".
20
- const REFERENCE_BEFORE = /\b(?:dossier|ref\.?|referentie|reference|référence|factuurnummer|invoice\s*(?:nr|number|no)?|bestelnummer|order\s*(?:nr|number|no)?|kenmerk|ordernummer|Aktenzeichen|numéro\s*de\s*(?:dossier|facture|commande)|bestellnummer|Rechnungsnummer|artikelnr|article\s*no|contract\s*(?:nr|number|no)?|pagina|page|Seite|blz\.?|Facture\s*n[°o]?|Faktura\s*n[°or]\.?|Lasku\s*n[°or]o?\.?|Rechnung\s*(?:Nr|n[°o])?|faktura\s*(?:nr|n[°o])?|bestilling\s*(?:nr|n[°o])?|bestelling\s*n[°or]\.?|Reikningur\s*nr|Factuur\s*n[ro]?\.?|Nota\s*n[ro]?\.?)\s*[:.]?\s*$/i;
29
+ const REFERENCE_BEFORE = /\b(?:dossier|ref\.?|referentie|reference|référence|factuurnummer|invoice\s*(?:nr|number|no)?|bestelnummer|order\s*(?:nr|number|no)?|kenmerk|ordernummer|Aktenzeichen|numéro\s*de\s*(?:dossier|facture|commande)|bestellnummer|Rechnungsnummer|artikelnr|article\s*no|contract\s*(?:nr|number|no)?|pagina|page|Seite|blz\.?|Facture\s*n[°o]?|Faktura\s*n[°or]\.?|Lasku\s*n[°or]o?\.?|Rechnung\s*(?:Nr|n[°o])?|faktura\s*(?:nr|n[°o])?|bestilling\s*(?:nr|n[°o])?|bestelling\s*n[°or]\.?|Reikningur\s*nr|ticket|incident\s*(?:report|nr|no)?|case\s*(?:nr|no|id)?|zaaknummer|meldingsnummer|Vorgangsnummer|Vorgang|Störungsmeldung|saksnummer|ärendenummer|sagsnummer|asianumero|Factuur\s*n[ro]?\.?|Nota\s*n[ro]?\.?)\s*[:.#]?\s*$/i;
30
+ // "#" before a number marks a reference in every language in scope — a ticket,
31
+ // an order, a line item — and never a postcode or a national identifier.
32
+ const HASH_BEFORE = /#\s*$/;
33
+ // A short uppercase tag hyphenated to the number: "IR-43433", "INC-2024".
34
+ const REF_PREFIX_BEFORE = /(?:^|[\s([])[A-Z]{2,5}-$/;
21
35
  const LEGAL_BEFORE = /(?:Art(?:ikel|icle|\.)|§|Artikel|Section|Sectie|Afdeling|paragraaf|Absatz|alinéa|punt|point|Punkt|lid)\s*$/i;
22
36
  const MATH_BEFORE = /[=+\-×÷*/]\s*$/;
23
37
  const MATH_AFTER = /^\s*[=+\-×÷*/]/;
@@ -49,7 +63,11 @@ const MONTH_NAMES = "january|february|march|april|may|june|july|august|september
49
63
  "|gennaio|febbraio|marzo|aprile|maggio|giugno|luglio|agosto|settembre|ottobre|dicembre" +
50
64
  "|enero|febrero|abril|mayo|junio|julio|septiembre|octubre|noviembre|diciembre" +
51
65
  "|janeiro|fevereiro|março|marco|maio|junho|julho|setembro|outubro|novembro|dezembro" +
52
- "|marts|augusti" +
66
+ // "desember" (nb/nn/is) was the one December spelling missing from a list
67
+ // that already carried ten others — "1. desember 2025" was a Norwegian
68
+ // postcode because of it.
69
+ "|marts|augusti|desember" +
70
+ "|janúar|febrúar|apríl|maí|júní|júlí|ágúst|nóvember" +
53
71
  "|tammikuu|helmikuu|maaliskuu|huhtikuu|toukokuu|kesäkuu|heinäkuu|elokuu|syyskuu|lokakuu|marraskuu|joulukuu" +
54
72
  "|jaanuar|veebruar|aprill|juuni|juuli|oktoober|detsember" +
55
73
  "|janvāris|februāris|aprīlis|maijs|jūnijs|jūlijs|augusts|septembris|oktobris|novembris|decembris" +
@@ -74,7 +92,11 @@ const DATE_AFTER = /^[./-]\d{1,2}[./-]\d{1,2}\b/;
74
92
  * ISO 4217 codes made only of the consonants a Spanish plate accepts, so a money
75
93
  * amount reads as a registration. This is why "2297 DKK" was a plate.
76
94
  */
77
- const CURRENCY_CODE = "(?:DKK|SEK|NOK|ISK|CZK|PLN|HUF|RON|BGN|HRK|CHF|GBP|TRY|RSD|MKD|BYN|KZT|CNY|JPY|KRW|ZAR|BRL|MXN|CLP|COP|PLZ|SKK|TRL)";
95
+ // Crypto tickers are the same shape and the same mistake — every surviving
96
+ // plate false positive was "4499 BTC" or a sibling. A ticker is a unit, exactly
97
+ // as an ISO 4217 code is.
98
+ const CURRENCY_CODE = "(?:DKK|SEK|NOK|ISK|CZK|PLN|HUF|RON|BGN|HRK|CHF|GBP|TRY|RSD|MKD|BYN|KZT|CNY|JPY|KRW|ZAR|BRL|MXN|CLP|COP|PLZ|SKK|TRL" +
99
+ "|BTC|ETH|LTC|XRP|BCH|XLM|XMR|DOT|SOL|ADA|TRX|DOGE|USDT|USDC)";
78
100
  const CURRENCY_CODE_TAIL = new RegExp(`\\s${CURRENCY_CODE}$`);
79
101
  const CURRENCY_CODE_AFTER = new RegExp(`^\\s*${CURRENCY_CODE}\\b`);
80
102
  /** Log-timestamp tails, which the ":"-anchored secret rule picks up. */
@@ -88,6 +110,32 @@ const NOT_A_SECRET = new RegExp("^(?:af|ap|ca|cn|eu|il|me|sa|us|gov)-(?:north|so
88
110
  const EXACT_UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
89
111
  const BIC_SHAPE = /^[A-Z]{6}[A-Z0-9]{2}(?:[A-Z0-9]{3})?$/;
90
112
  const BIC_CUE_BEFORE = /(?:BIC|SWIFT|BIC\/SWIFT)\s*[:=]?\s*\(?\s*$/i;
113
+ /** An email address, exactly. Same argument as the UUID and the BIC. */
114
+ const EXACT_EMAIL = /^[\w.!#$%&'*+/=?^`{|}~-]+@[\w-]+(?:\.[\w-]+)+$/;
115
+ /**
116
+ * A URL, or the tail of one after the scheme has been split off. The secret
117
+ * rules anchor on ":" and "=", so "https://api.sendgrid.com/v3/mail/send" hands
118
+ * them "//api.sendgrid.com/v3/mail/send" and a slash-separated path scores as
119
+ * high-entropy. An endpoint is published documentation, not a credential.
120
+ */
121
+ const URL_LIKE = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^\s/]+(?:\/\S*)?$|^[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,}(?::\d{1,5})?(?:\/\S*)?$/i;
122
+ /**
123
+ * A URL that *carries* a credential is the opposite case, and it is the common
124
+ * one: "mongodb://admin:DFKDKi1eb51OOhuHPYz@rds-main.eu-west-1.rds.amazonaws.com"
125
+ * is a connection string with a live password in it. Suppressing those as "just
126
+ * a URL" lost 347 real secrets, so the endpoint test must not fire when userinfo
127
+ * or a credential-bearing query parameter is present.
128
+ */
129
+ const URL_WITH_CREDENTIALS = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^/@\s]*:[^/@\s]*@|[?&](?:api_?key|access_?token|auth|token|secret|password|pwd|sig|signature|credential)=/i;
130
+ /** An LDAP distinguished name: "cn=github-actions,dc=corp,dc=eu". */
131
+ const LDAP_DN = /^(?:cn|ou|dc|uid|o|l|st|c)=[^,=]+(?:,\s*(?:cn|ou|dc|uid|o|l|st|c)=[^,=]+)+$/i;
132
+ /**
133
+ * Lower-case words joined by hyphens, optionally with a short id suffix:
134
+ * "service-account", "data-lake-33e061". The single-word test below already
135
+ * rejects "sozialversicherungsnummer" on the reasoning that a real secret would
136
+ * be mixed case; a hyphenated compound is the same token with a separator.
137
+ */
138
+ const HYPHENATED_WORDS = /^[a-z]{2,}(?:-[a-z]{2,})+(?:-[0-9a-f]{4,8})?$/;
91
139
  /** A dotted quad with every octet in range. */
92
140
  const DOTTED_QUAD = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
93
141
  /** Two plausible years joined by a hyphen — a school year, a contract term. */
@@ -117,7 +165,7 @@ function suppressSecretOverStructured(text, match) {
117
165
  if (match.patternDef.entityType !== EntityType.SECRET)
118
166
  return false;
119
167
  const token = coreToken(match.text);
120
- if (EXACT_UUID.test(token))
168
+ if (EXACT_UUID.test(token) || EXACT_EMAIL.test(token))
121
169
  return true;
122
170
  if (BIC_SHAPE.test(token)) {
123
171
  return BIC_CUE_BEFORE.test(text.slice(Math.max(0, match.start - 24), match.start));
@@ -142,6 +190,12 @@ function suppressSecretNotASecret(_text, match) {
142
190
  return true;
143
191
  if (NOT_A_SECRET.test(bare))
144
192
  return true;
193
+ if (URL_LIKE.test(bare) && !URL_WITH_CREDENTIALS.test(bare))
194
+ return true;
195
+ if (LDAP_DN.test(bare))
196
+ return true;
197
+ if (HYPHENATED_WORDS.test(bare))
198
+ return true;
145
199
  const alpha = /^[A-Za-zÀ-ÿ]+$/.test(bare);
146
200
  const oneWord = bare === bare.toLowerCase() ||
147
201
  (bare.slice(0, 1) === bare.slice(0, 1).toUpperCase() &&
@@ -202,12 +256,27 @@ function suppressUnits(text, match) {
202
256
  const [, after] = getContext(text, match.start, match.end);
203
257
  return UNIT_AFTER.test(after);
204
258
  }
259
+ /**
260
+ * Suppress numbers preceded by reference/invoice/dossier keywords.
261
+ *
262
+ * POSTAL_CODE belongs here for the same reason every other numeric type does,
263
+ * and its absence was an oversight: a five-digit ticket number sits in exactly
264
+ * the shape a German or French postcode occupies, so "Ticket #94730" and
265
+ * "Incident report IR-43433" were masked as addresses.
266
+ *
267
+ * "#" and "XX-" are checked adjacently rather than through the 150-character
268
+ * keyword window. That window is what made the postal rule claim years in
269
+ * dates; widening its reach to fix a different symptom would repeat the error.
270
+ */
205
271
  function suppressReference(text, match) {
206
- const applicable = new Set([EntityType.PHONE, EntityType.NATIONAL_ID, EntityType.SSN, EntityType.TAX_ID, EntityType.IBAN, EntityType.CHAMBER_OF_COMMERCE]);
272
+ const applicable = new Set([EntityType.PHONE, EntityType.NATIONAL_ID, EntityType.SSN, EntityType.TAX_ID, EntityType.IBAN, EntityType.CHAMBER_OF_COMMERCE, EntityType.POSTAL_CODE]);
207
273
  if (!applicable.has(match.patternDef.entityType))
208
274
  return false;
209
275
  const [before] = getContext(text, match.start, match.end);
210
- return REFERENCE_BEFORE.test(before);
276
+ if (REFERENCE_BEFORE.test(before))
277
+ return true;
278
+ const adjacent = text.slice(Math.max(0, match.start - 8), match.start);
279
+ return HASH_BEFORE.test(adjacent) || REF_PREFIX_BEFORE.test(adjacent);
211
280
  }
212
281
  function suppressLegal(text, match) {
213
282
  const applicable = new Set([EntityType.PHONE, EntityType.NATIONAL_ID, EntityType.POSTAL_CODE]);
@@ -661,7 +730,7 @@ const TYPE_SUPPRESSORS = {
661
730
  [EntityType.NATIONAL_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressLegal, suppressMath, suppressNatidAsPassport, suppressSeNatidAsOrg],
662
731
  [EntityType.SSN]: [suppressCurrency, suppressUnits, suppressReference, suppressMath],
663
732
  [EntityType.TAX_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressTaxidAsIpAddress],
664
- [EntityType.POSTAL_CODE]: [suppressCurrency, suppressUnits, suppressMath, suppressLegal, suppressYearAsPostal, suppressPostalInsideIban, suppressPostalAsHouseNumber, suppressPostalInLongerIdentifier],
733
+ [EntityType.POSTAL_CODE]: [suppressCurrency, suppressUnits, suppressMath, suppressLegal, suppressYearAsPostal, suppressPostalInsideIban, suppressPostalAsHouseNumber, suppressPostalInLongerIdentifier, suppressReference],
665
734
  [EntityType.BIC]: [suppressBicWithoutEvidence],
666
735
  [EntityType.BANK_ACCOUNT]: [suppressReference],
667
736
  [EntityType.LICENSE_PLATE]: [suppressPlateInCompound, suppressDePlateUnknownDistrict, suppressPlateAsCurrencyAmount],
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "euredact",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "European PII redaction SDK -- fast, zero-dependency, rule-based detection and redaction of personal data across 31 European countries",
5
+ "homepage": "https://euredact.dev",
5
6
  "license": "Apache-2.0",
6
7
  "type": "module",
7
8
  "main": "./dist/cjs/index.js",
@@ -66,5 +67,8 @@
66
67
  "devDependencies": {
67
68
  "typescript": "^5.4.0",
68
69
  "tsx": "^4.7.0"
70
+ },
71
+ "bugs": {
72
+ "url": "https://github.com/euredact/euredact/issues"
69
73
  }
70
74
  }