euredact 0.3.6 → 0.3.8

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.
@@ -40,11 +40,16 @@ const MATH_AFTER = /^\s*[=+\-×÷*/]/;
40
40
  const COUNTRY_PREFIX_HYPHEN = /(?:^|[^A-Za-z0-9])[A-Z]{1,2}-\s*$/;
41
41
  const SEQUENTIAL_PATTERNS = /^(?:0{6,}|1234567890?|0123456789|9876543210?|1111111111?|000000000|123456789)$/;
42
42
  // YEAR_PATTERN kept for reference but replaced by RECENT_YEAR in suppressYearAsPostal
43
- // The leading \b is load-bearing. Without it the short alternatives match the
44
- // *tail* of an ordinary word: "NIS" matched "tālru**nis** ", the Latvian for
45
- // "telephone", so every Latvian phone number was suppressed as though it sat
46
- // behind a Belgian national-ID label. 653 misses from a missing word boundary.
47
- const ID_LABEL_BEFORE = /\b(?:BSN|RR|NN|NIR|INSZ|NISS|NIS|Steuer-?ID|TIN|NIF|NIE|SSN|rijksregisternummer|numéro\s*national|national\s*number|matricule|Ausweisnummer|Personalausweis|Versichertennummer|KVNR|KV-Nr|Steuernummer|St\.\-Nr|StNr|Finanzamt\s+ist|Ondernemingen\s+onder\s+nummer|ondernemingsnummer|numéro\s*d'entreprise|enterprise\s*number|Kruispuntbank)\s*[:.]?\s*$/i;
43
+ // `ID_LABEL_BEFORE` and `suppressPhoneAfterIdLabel` lived here. Their labels are
44
+ // now typed entries in `cues.ts`, and a phone-shaped span behind one is
45
+ // *relabelled* rather than dropped.
46
+ //
47
+ // Dropping was the wrong verb. The span is found either way, so removing the
48
+ // claim decided only whether the value was masked — and the answer was "no":
49
+ // "Rijksregisternummer: 85.03.19-284.73" produced no detection at all, a
50
+ // redaction library printing in full an identifier it had recognised and
51
+ // rejected. The 653-miss word-boundary lesson recorded here moved with the
52
+ // labels; see the module comment in `cues.ts`.
48
53
  const SERVICE_NUMBER = /^0800[\-\s]/;
49
54
  const DATE_PATTERN_FULL = /^\d{2}[-/.]\d{2}[-/.]\d{4}$|^\d{4}[-/.]\d{2}[-/.]\d{2}$/;
50
55
  const PASSPORT_CONTEXT_BEFORE = /(?:Reisepass|passport|passeport|paspoort|Bisheriger\s+Reisepass)\s*(?:Nr\.?|Nummer|nummer|number|n[°o])?\s*[:.]?\s*$/i;
@@ -268,8 +273,12 @@ function suppressUnits(text, match) {
268
273
  * keyword window. That window is what made the postal rule claim years in
269
274
  * dates; widening its reach to fix a different symptom would repeat the error.
270
275
  */
276
+ // Hoisted out of the suppressor bodies: rebuilding these per candidate cost
277
+ // ~260k Set allocations per 1 MB document, and GC was 10.7% of the profile.
278
+ const REFERENCE_TYPES = new Set([EntityType.PHONE, EntityType.NATIONAL_ID, EntityType.SSN, EntityType.TAX_ID, EntityType.IBAN, EntityType.CHAMBER_OF_COMMERCE, EntityType.POSTAL_CODE]);
279
+ const LEGAL_TYPES = new Set([EntityType.PHONE, EntityType.NATIONAL_ID, EntityType.POSTAL_CODE]);
271
280
  function suppressReference(text, match) {
272
- const applicable = new Set([EntityType.PHONE, EntityType.NATIONAL_ID, EntityType.SSN, EntityType.TAX_ID, EntityType.IBAN, EntityType.CHAMBER_OF_COMMERCE, EntityType.POSTAL_CODE]);
281
+ const applicable = REFERENCE_TYPES;
273
282
  if (!applicable.has(match.patternDef.entityType))
274
283
  return false;
275
284
  const [before] = getContext(text, match.start, match.end);
@@ -279,7 +288,7 @@ function suppressReference(text, match) {
279
288
  return HASH_BEFORE.test(adjacent) || REF_PREFIX_BEFORE.test(adjacent);
280
289
  }
281
290
  function suppressLegal(text, match) {
282
- const applicable = new Set([EntityType.PHONE, EntityType.NATIONAL_ID, EntityType.POSTAL_CODE]);
291
+ const applicable = LEGAL_TYPES;
283
292
  if (!applicable.has(match.patternDef.entityType))
284
293
  return false;
285
294
  const [before] = getContext(text, match.start, match.end);
@@ -334,12 +343,6 @@ function suppressYearAsPostal(text, match) {
334
343
  // Suppress: recent years without postal context are almost never postal codes
335
344
  return true;
336
345
  }
337
- function suppressPhoneAfterIdLabel(text, match) {
338
- if (match.patternDef.entityType !== EntityType.PHONE)
339
- return false;
340
- const [before] = getContext(text, match.start, match.end);
341
- return ID_LABEL_BEFORE.test(before);
342
- }
343
346
  function suppressPhoneServiceNumber(_text, match) {
344
347
  if (match.patternDef.entityType !== EntityType.PHONE)
345
348
  return false;
@@ -502,8 +505,15 @@ function enclosingLine(text, start, end) {
502
505
  */
503
506
  function structuralUnit(text, start, end) {
504
507
  const [lineStart, lineEnd] = enclosingLine(text, start, end);
508
+ // Both walks stop as soon as the paragraph is too wide to be used, because
509
+ // everything past that point is discarded by the cap below anyway. Applying
510
+ // the cap only to the *result* meant that on a document with no blank lines
511
+ // each walk ran to the top and bottom of the whole text — O(document) per
512
+ // candidate, O(n^2) overall.
505
513
  let paraStart = lineStart;
506
514
  while (paraStart > 0) {
515
+ if (lineEnd - paraStart > MAX_UNIT_CHARS)
516
+ return text.slice(lineStart, lineEnd);
507
517
  const prevEnd = paraStart - 1;
508
518
  const prevStart = text.lastIndexOf("\n", prevEnd - 1) + 1;
509
519
  if (text.slice(prevStart, prevEnd).trim() === "")
@@ -512,6 +522,8 @@ function structuralUnit(text, start, end) {
512
522
  }
513
523
  let paraEnd = lineEnd;
514
524
  while (paraEnd < text.length) {
525
+ if (paraEnd - paraStart > MAX_UNIT_CHARS)
526
+ return text.slice(lineStart, lineEnd);
515
527
  const nextStart = paraEnd + 1;
516
528
  let nextEnd = text.indexOf("\n", nextStart);
517
529
  if (nextEnd === -1)
@@ -548,28 +560,73 @@ function escapeRe(s) {
548
560
  * heading. Measured on the corpus this alone identifies ~78% of the BIC false
549
561
  * positives with no dictionaries and no labelled data.
550
562
  */
551
- function occursAsLowercaseWord(text, token) {
563
+ /**
564
+ * Every all-letter word in the document that is not part of an email address
565
+ * or a domain name.
566
+ *
567
+ * `\b<word>\b` can only match a maximal run of word characters, so collecting
568
+ * the runs once answers the question for every candidate. Re-scanning the whole
569
+ * document per candidate made this quadratic: 1.58 s on a 400 KB document
570
+ * dense in bank codes, against ~1 ms to build this set.
571
+ */
572
+ function buildDocumentWords(text) {
573
+ const words = new Set();
574
+ const re = /[A-Za-z0-9_]+/g;
575
+ let m;
576
+ while ((m = re.exec(text)) !== null) {
577
+ const word = m[0];
578
+ if (!/^[A-Za-z]+$/.test(word))
579
+ continue;
580
+ const i = m.index;
581
+ const before = i > 0 ? text[i - 1] : "";
582
+ if (before === "@")
583
+ continue;
584
+ if (before === "." && i >= 2 && /[A-Za-z0-9]/.test(text[i - 2]))
585
+ continue;
586
+ const after = text.slice(i + word.length, i + word.length + 8);
587
+ if (after.startsWith("@"))
588
+ continue;
589
+ if (/^\.[a-zA-Z]{2,6}\b/.test(after))
590
+ continue;
591
+ words.add(word);
592
+ }
593
+ return words;
594
+ }
595
+ /**
596
+ * Per-document scratch space for suppressors that would otherwise re-derive the
597
+ * same whole-document facts for every candidate.
598
+ *
599
+ * Built lazily and held only for the duration of one `detect()` call, so no
600
+ * document text outlives the call that supplied it.
601
+ */
602
+ export class SuppressionScratch {
603
+ text;
604
+ words = null;
605
+ constructor(text) {
606
+ this.text = text;
607
+ }
608
+ documentWords() {
609
+ if (this.words === null)
610
+ this.words = buildDocumentWords(this.text);
611
+ return this.words;
612
+ }
613
+ }
614
+ /**
615
+ * Does `token` also occur as an ordinary lowercase word in this document?
616
+ *
617
+ * `hospital`/`HOSPITAL`, `gegevens`/`GEGEVENS` — a token that appears in
618
+ * ordinary case elsewhere in the same document is a word, not a bank code.
619
+ * Email and domain contexts are excluded, so `ing.nl` does not vouch for a
620
+ * heading. Measured on the corpus this alone identifies ~78% of the BIC false
621
+ * positives with no dictionaries and no labelled data.
622
+ */
623
+ function occursAsLowercaseWord(text, token, scratch) {
552
624
  if (!/^[A-Za-z]+$/.test(token))
553
625
  return false;
554
626
  const lower = token.toLowerCase();
555
627
  const capitalised = lower[0].toUpperCase() + lower.slice(1);
556
- for (const form of [lower, capitalised]) {
557
- // Cheap pre-check: a plain substring test almost always answers "no".
558
- if (!text.includes(form))
559
- continue;
560
- const re = new RegExp(`\\b${escapeRe(form)}\\b`, "g");
561
- let m;
562
- while ((m = re.exec(text)) !== null) {
563
- const before = m.index > 0 ? text[m.index - 1] : "";
564
- const after = text.slice(m.index + form.length, m.index + form.length + 8);
565
- if (before === "@" || (before === "." && m.index >= 2 && /[A-Za-z0-9]/.test(text[m.index - 2])))
566
- continue;
567
- if (after.startsWith("@") || /^\.[a-zA-Z]{2,6}\b/.test(after))
568
- continue;
569
- return true;
570
- }
571
- }
572
- return false;
628
+ const words = scratch ? scratch.documentWords() : buildDocumentWords(text);
629
+ return words.has(lower) || words.has(capitalised);
573
630
  }
574
631
  /**
575
632
  * Is the candidate positioned as a section heading rather than a value?
@@ -607,25 +664,29 @@ function isHeadingShape(text, start, end, token) {
607
664
  * ordinary lowercase word — so it is applied on both emitting paths. It scans
608
665
  * the whole document, so it runs only on the paths that would otherwise emit.
609
666
  */
610
- function suppressBicWithoutEvidence(text, match) {
667
+ function suppressBicWithoutEvidence(text, match, scratch) {
611
668
  if (match.patternDef.entityType !== EntityType.BIC)
612
669
  return false;
613
670
  const token = match.text.trim();
614
671
  if (isRegisteredBic(token))
615
- return occursAsLowercaseWord(text, token);
672
+ return occursAsLowercaseWord(text, token, scratch);
616
673
  if (isHeadingShape(text, match.start, match.end, token))
617
674
  return true;
618
675
  // The token itself is blanked out so it cannot vouch for itself.
619
676
  const unit = structuralUnit(text, match.start, match.end).split(token).join(" ".repeat(token.length));
620
677
  if (BIC_KEYWORD.test(unit) || IBAN_SHAPE.test(unit) || BANK_BLOCK.test(unit)) {
621
- return occursAsLowercaseWord(text, token);
678
+ return occursAsLowercaseWord(text, token, scratch);
622
679
  }
623
680
  return true;
624
681
  }
625
682
  // ── Postal code: digits belonging to a longer identifier ────────────────
626
683
  // An identifier label immediately before the digits. A postal code is never
627
684
  // introduced this way; an SVNr, policy number or service number always is.
628
- const ID_CUE_BEFORE = /(?:[\w\-]*Nr|[\w\-]*N[°ºo]|[\w\-]*Nummer|[\w\-]*Numero|[\w\-]*Numéro|No|number|num|Kennzahl|Aktenzeichen|Az|e-?card|Polizze|Police|Policen)\.?\s*:?\s*$/i;
685
+ // The `[\w\-]*` prefix is factored out of the five alternatives that share it.
686
+ // Repeated per alternative, the engine retried the same unbounded prefix five
687
+ // times per starting position — the most expensive single regex in the engine
688
+ // at 197 ms per 1 MB document. Same language, 8x faster on the worst case.
689
+ const ID_CUE_BEFORE = /(?:[\w\-]*(?:Nr|N[°ºo]|Nummer|Numero|Numéro)|No|number|num|Kennzahl|Aktenzeichen|Az|e-?card|Polizze|Police|Policen)\.?\s*:?\s*$/i;
629
690
  // An international dialling prefix earlier on the same line, with nothing but
630
691
  // number punctuation in between: these digits belong to the phone detector.
631
692
  const DIALLING_PREFIX_BEFORE = /\+\d{1,3}[\d\s\-().]*$/;
@@ -726,7 +787,7 @@ function suppressRequiresContext(text, match) {
726
787
  return !match.patternDef.contextKeywords.some(kw => context.includes(kw.toLowerCase()));
727
788
  }
728
789
  const TYPE_SUPPRESSORS = {
729
- [EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressPhoneAfterIdLabel, suppressPhoneServiceNumber, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
790
+ [EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressPhoneServiceNumber, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
730
791
  [EntityType.NATIONAL_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressLegal, suppressMath, suppressNatidAsPassport, suppressSeNatidAsOrg],
731
792
  [EntityType.SSN]: [suppressCurrency, suppressUnits, suppressReference, suppressMath],
732
793
  [EntityType.TAX_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressTaxidAsIpAddress],
@@ -737,13 +798,13 @@ const TYPE_SUPPRESSORS = {
737
798
  [EntityType.SECRET]: [suppressSecretOverStructured, suppressSecretNotASecret],
738
799
  [EntityType.CHAMBER_OF_COMMERCE]: [suppressReference],
739
800
  };
740
- export function shouldSuppress(text, match) {
801
+ export function shouldSuppress(text, match, scratch) {
741
802
  if (suppressSequential(text, match))
742
803
  return true;
743
804
  const typeSups = TYPE_SUPPRESSORS[match.patternDef.entityType];
744
805
  if (typeSups) {
745
806
  for (const s of typeSups) {
746
- if (s(text, match))
807
+ if (s(text, match, scratch))
747
808
  return true;
748
809
  }
749
810
  }
@@ -17,6 +17,18 @@ export declare function validateVatFr(candidate: string): boolean;
17
17
  export declare function validateVatLu(candidate: string): boolean;
18
18
  export declare function validateGermanTaxId(candidate: string): boolean;
19
19
  export declare function validateFrenchNir(candidate: string): boolean;
20
+ /**
21
+ * ISO 3779 VIN validation: 17 characters, no I/O/Q. **Shape only.**
22
+ *
23
+ * The position-9 check digit is mandatory for North American VINs (WMI 1-5)
24
+ * and unused elsewhere, and it is deliberately **not** enforced. Measured over
25
+ * the 152,300-document corpus, enforcing it turned 1,502 labelled VINs into
26
+ * misses — every North American VIN whose check digit does not survive being
27
+ * written down. A VIN that fails its checksum is still a VIN sitting in the
28
+ * text; dropping it leaves it unredacted, which is a leak, while keeping it
29
+ * costs at worst an over-masked 17-character token. Matches the Python SDK's
30
+ * `validate_vin`, which carries the full reasoning.
31
+ */
20
32
  export declare function validateVin(candidate: string): boolean;
21
33
  /**
22
34
  * BIC/SWIFT structural validation (ISO 9362): BBBB CC LL (PPP).
@@ -179,6 +179,18 @@ export function validateFrenchNir(candidate) {
179
179
  const check = parseInt(numeric.slice(13, 15));
180
180
  return (97 - (first13 % 97)) === check;
181
181
  }
182
+ /**
183
+ * ISO 3779 VIN validation: 17 characters, no I/O/Q. **Shape only.**
184
+ *
185
+ * The position-9 check digit is mandatory for North American VINs (WMI 1-5)
186
+ * and unused elsewhere, and it is deliberately **not** enforced. Measured over
187
+ * the 152,300-document corpus, enforcing it turned 1,502 labelled VINs into
188
+ * misses — every North American VIN whose check digit does not survive being
189
+ * written down. A VIN that fails its checksum is still a VIN sitting in the
190
+ * text; dropping it leaves it unredacted, which is a leak, while keeping it
191
+ * costs at worst an over-masked 17-character token. Matches the Python SDK's
192
+ * `validate_vin`, which carries the full reasoning.
193
+ */
182
194
  export function validateVin(candidate) {
183
195
  const c = candidate.replace(/[\s\-]/g, "").toUpperCase();
184
196
  if (c.length !== 17)
package/dist/esm/sdk.js CHANGED
@@ -4,20 +4,44 @@ import { RuleEngine } from "./rules/engine.js";
4
4
  import { weightsToRanking } from "./rules/evidence.js";
5
5
  import { EntityType } from "./types.js";
6
6
  const DATE_TYPES = new Set([EntityType.DOB, EntityType.DATE_OF_DEATH]);
7
+ /** Entry count at which a one-time warning is emitted. */
8
+ const MAPPING_WARN_THRESHOLD = 100_000;
9
+ /**
10
+ * Maps real PII values to consistent labels within a session.
11
+ *
12
+ * The mapping is keyed on the **raw PII value** and is never evicted — evicting
13
+ * would hand a previously seen value a second label and quietly break
14
+ * referential integrity. Two consequences worth designing around:
15
+ *
16
+ * - It grows for as long as the process runs. Call `clear()` between workloads;
17
+ * a warning is emitted once the mapping passes `MAPPING_WARN_THRESHOLD`.
18
+ * - Labels are shared by every caller of the same instance, including every
19
+ * caller of the module-level `redact()`. A label repeated across two
20
+ * documents reveals that they contain the same underlying value, so give each
21
+ * tenant its own `EuRedact` instance rather than sharing the module-level one.
22
+ */
7
23
  class ReferentialMapper {
8
24
  counters = new Map();
9
25
  mapping = new Map();
26
+ warned = false;
10
27
  getLabel(text, entityType) {
11
28
  if (!this.mapping.has(text)) {
12
29
  const count = (this.counters.get(entityType) ?? 0) + 1;
13
30
  this.counters.set(entityType, count);
14
31
  this.mapping.set(text, `${entityType}_${count}`);
32
+ if (!this.warned && this.mapping.size > MAPPING_WARN_THRESHOLD) {
33
+ this.warned = true;
34
+ console.warn(`[euredact] Referential integrity mapping holds ${this.mapping.size} ` +
35
+ `raw PII values and is never evicted. Call clear() between workloads ` +
36
+ `to release them.`);
37
+ }
15
38
  }
16
39
  return this.mapping.get(text);
17
40
  }
18
41
  clear() {
19
42
  this.counters.clear();
20
43
  this.mapping.clear();
44
+ this.warned = false;
21
45
  }
22
46
  }
23
47
  const DEFAULT_MAX_INPUT_LENGTH = 10_485_760; // ~10 MB of text
@@ -107,14 +131,32 @@ export class EuRedact {
107
131
  detections = detections.filter(d => !DATE_TYPES.has(d.entityType));
108
132
  }
109
133
  detections.sort((a, b) => a.start - b.start || b.end - a.end);
110
- let redacted = text;
134
+ // Labels are resolved right-to-left because the referential mapper numbers
135
+ // each entity type in call order, and that order is part of the output
136
+ // contract. The string itself is then assembled in a single forward pass:
137
+ // rebuilding it per detection copied the whole document each time, which is
138
+ // O(document x detections) — 1.68 s of pure copying on a 1 MB document with
139
+ // 15,000 detections, versus 2 ms here.
140
+ const replacements = new Array(detections.length);
111
141
  for (let i = detections.length - 1; i >= 0; i--) {
112
142
  const det = detections[i];
113
- const replacement = referentialIntegrity
143
+ replacements[i] = referentialIntegrity
114
144
  ? this.referentialMapper.getLabel(det.text, det.entityType)
115
145
  : `[${det.entityType}]`;
116
- redacted = redacted.slice(0, det.start) + replacement + redacted.slice(det.end);
117
146
  }
147
+ const parts = [];
148
+ let pos = 0;
149
+ for (let i = 0; i < detections.length; i++) {
150
+ const det = detections[i];
151
+ // Spans reach here deduplicated and non-overlapping; one starting behind
152
+ // the cursor would splice into the middle of a label, so drop it.
153
+ if (det.start < pos)
154
+ continue;
155
+ parts.push(text.slice(pos, det.start), replacements[i]);
156
+ pos = det.end;
157
+ }
158
+ parts.push(text.slice(pos));
159
+ const redacted = parts.join("");
118
160
  // Report the inference so it can be audited. Spans in `evidence` are
119
161
  // offsets into the normalised text, matching `detections`.
120
162
  const inferredCountries = [...weightsToRanking(scores).entries()]
@@ -32,6 +32,16 @@ export declare enum EntityType {
32
32
  HEALTH_INSURANCE = "HEALTH_INSURANCE",
33
33
  HEALTHCARE_PROVIDER = "HEALTHCARE_PROVIDER",
34
34
  CHAMBER_OF_COMMERCE = "CHAMBER_OF_COMMERCE",
35
+ /**
36
+ * Employee, badge or customer number tied to a person.
37
+ *
38
+ * Emitted **only** when an explicit label names it — see `rules/cues.ts`.
39
+ * There is no pattern for an internal identifier because there is no shape
40
+ * for one; without the label, a digit run is not distinguishable from any
41
+ * other. The type exists so that a labelled employee number is filed
42
+ * correctly instead of being claimed by the phone pattern.
43
+ */
44
+ INTERNAL_ID = "INTERNAL_ID",
35
45
  IMEI = "IMEI",
36
46
  GPS_COORDINATES = "GPS_COORDINATES",
37
47
  UUID = "UUID",
package/dist/esm/types.js CHANGED
@@ -33,6 +33,16 @@ export var EntityType;
33
33
  EntityType["HEALTH_INSURANCE"] = "HEALTH_INSURANCE";
34
34
  EntityType["HEALTHCARE_PROVIDER"] = "HEALTHCARE_PROVIDER";
35
35
  EntityType["CHAMBER_OF_COMMERCE"] = "CHAMBER_OF_COMMERCE";
36
+ /**
37
+ * Employee, badge or customer number tied to a person.
38
+ *
39
+ * Emitted **only** when an explicit label names it — see `rules/cues.ts`.
40
+ * There is no pattern for an internal identifier because there is no shape
41
+ * for one; without the label, a digit run is not distinguishable from any
42
+ * other. The type exists so that a labelled employee number is filed
43
+ * correctly instead of being claimed by the phone pattern.
44
+ */
45
+ EntityType["INTERNAL_ID"] = "INTERNAL_ID";
36
46
  EntityType["IMEI"] = "IMEI";
37
47
  EntityType["GPS_COORDINATES"] = "GPS_COORDINATES";
38
48
  EntityType["UUID"] = "UUID";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euredact",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "European PII redaction SDK -- fast, zero-dependency, rule-based detection and redaction of personal data across 31 European countries",
5
5
  "homepage": "https://euredact.dev",
6
6
  "license": "Apache-2.0",
@@ -65,8 +65,9 @@
65
65
  "dump": "node --import tsx src/__tests__/dump.ts"
66
66
  },
67
67
  "devDependencies": {
68
- "typescript": "^5.4.0",
69
- "tsx": "^4.7.0"
68
+ "@types/node": "22.10.2",
69
+ "tsx": "^4.7.0",
70
+ "typescript": "^5.4.0"
70
71
  },
71
72
  "bugs": {
72
73
  "url": "https://github.com/euredact/euredact/issues"