phi-gate 0.2.0__py3-none-any.whl

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.
phi_gate/detectors.py ADDED
@@ -0,0 +1,1086 @@
1
+ """Pattern-based PHI/PII detectors.
2
+
3
+ Design rule: a Finding never carries the matched value. Anything that needs
4
+ the value (redaction) slices it from the original text itself, so findings
5
+ can be printed, logged, or serialized without leaking what they found.
6
+
7
+ Each detector returns character spans. Labelled detectors (for example
8
+ "DOB: <date>") return the span of the value only, not the label, so that
9
+ redaction keeps the surrounding text readable.
10
+
11
+ Detection runs on a normalised view of the text (see `normalize`): Unicode
12
+ compatibility forms are folded, invisible format characters are removed, and
13
+ look-alike letters are mapped to ASCII, so that a zero-width space inside an
14
+ SSN or a Cyrillic letter inside a label cannot hide a value. Spans are mapped
15
+ back to the original text before they are returned.
16
+
17
+ Every pattern is written to run in linear time on hostile input: repetition
18
+ next to an optional separator uses possessive quantifiers or a fixed bound, so
19
+ a long run of spaces, digits or label words cannot make the engine backtrack.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import bisect
25
+ import ipaddress
26
+ import re
27
+ import unicodedata
28
+ from array import array
29
+ from collections.abc import Callable, Iterator
30
+ from dataclasses import dataclass
31
+ from typing import Final, Literal
32
+
33
+ Severity = Literal["high", "medium", "low"]
34
+ Group = Literal["direct", "person", "clinical", "contact", "provider", "network"]
35
+
36
+ HIGH: Final[Severity] = "high"
37
+ MEDIUM: Final[Severity] = "medium"
38
+ LOW: Final[Severity] = "low"
39
+ SEVERITY_RANK: Final[dict[Severity, int]] = {LOW: 0, MEDIUM: 1, HIGH: 2}
40
+
41
+ # Groups drive the record-likeness rule in policy.py.
42
+ DIRECT: Final[Group] = "direct" # identifies a person on its own (SSN, MRN, DOB...)
43
+ PERSON: Final[Group] = "person" # a person's name in a labelled field
44
+ CLINICAL: Final[Group] = "clinical" # diagnosis codes, dates of service
45
+ CONTACT: Final[Group] = "contact" # phone, email, street address
46
+ PROVIDER: Final[Group] = "provider" # provider identifiers (NPI, DEA): not PHI on their own
47
+ NETWORK: Final[Group] = "network" # IP addresses
48
+
49
+ Span = tuple[int, int]
50
+ Finder = Callable[[str], Iterator[Span]]
51
+ Validator = Callable[[re.Match[str]], bool]
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Detector:
56
+ """One kind of identifier: its category, severity, group and finder."""
57
+
58
+ name: str
59
+ severity: Severity
60
+ group: Group
61
+ description: str
62
+ find: Finder
63
+ # Lowercase substrings, at least one of which any match must contain. A
64
+ # substring test is far cheaper than a regex tried at every position, and
65
+ # most source files contain none of these words.
66
+ hints: tuple[str, ...] = ()
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Finding:
71
+ """Where a detector matched. Never the matched value itself."""
72
+
73
+ category: str
74
+ severity: Severity
75
+ group: Group
76
+ start: int
77
+ end: int
78
+ line: int = 0
79
+ col: int = 0
80
+
81
+ def to_dict(self) -> dict[str, str | int]:
82
+ """The finding as JSON-ready data, without offsets into the text."""
83
+ return {
84
+ "category": self.category,
85
+ "severity": self.severity,
86
+ "group": self.group,
87
+ "line": self.line,
88
+ "col": self.col,
89
+ }
90
+
91
+
92
+ # --------------------------------------------------------------------------
93
+ # Normalisation
94
+ # --------------------------------------------------------------------------
95
+
96
+ # Letters from other scripts that render like ASCII letters. Folding them lets a
97
+ # label such as "SSN" written with a Cyrillic first letter still read as a label.
98
+ _CONFUSABLES: Final[dict[int, str]] = {
99
+ # Cyrillic capitals and small letters
100
+ 0x0410: "A", 0x0412: "B", 0x0421: "C", 0x0415: "E", 0x041D: "H", 0x0406: "I",
101
+ 0x0408: "J", 0x041A: "K", 0x041C: "M", 0x041E: "O", 0x0420: "P", 0x0405: "S",
102
+ 0x0422: "T", 0x0425: "X", 0x0423: "Y", 0x04AE: "Y",
103
+ 0x0430: "a", 0x0441: "c", 0x0501: "d", 0x0435: "e", 0x0456: "i", 0x0458: "j",
104
+ 0x043E: "o", 0x0440: "p", 0x051B: "q", 0x0455: "s", 0x051D: "w", 0x0445: "x",
105
+ 0x0443: "y",
106
+ # Greek capitals and small omicron
107
+ 0x0391: "A", 0x0392: "B", 0x0395: "E", 0x0396: "Z", 0x0397: "H", 0x0399: "I",
108
+ 0x039A: "K", 0x039C: "M", 0x039D: "N", 0x039F: "O", 0x03A1: "P", 0x03A4: "T",
109
+ 0x03A5: "Y", 0x03A7: "X", 0x03BF: "o",
110
+ }
111
+ # Characters outside the dash punctuation category that are used as hyphens.
112
+ _EXTRA_DASHES: Final = frozenset("\u2212\u2043\ufe63\uff0d")
113
+ _NON_ASCII_RUN: Final = re.compile(r"[^\x00-\x7f]+")
114
+
115
+
116
+ def _fold_char(ch: str) -> str:
117
+ """The normalised form of one non-ASCII character (possibly empty)."""
118
+ out = []
119
+ for c in unicodedata.normalize("NFKC", ch):
120
+ if c < "\x80":
121
+ out.append(c)
122
+ continue
123
+ category = unicodedata.category(c)
124
+ if category == "Cf": # zero-width characters, soft hyphen, BOM
125
+ continue
126
+ if category == "Pd" or c in _EXTRA_DASHES:
127
+ out.append("-")
128
+ elif category == "Zs":
129
+ out.append(" ")
130
+ else:
131
+ out.append(_CONFUSABLES.get(ord(c), c))
132
+ return "".join(out)
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class NormalizedText:
137
+ """A normalised view of a text, with a map from view offsets back to it.
138
+
139
+ `offsets[i]` is the index in the original text of the character that
140
+ produced view character `i`; `offsets` is None when the view is the text.
141
+ """
142
+
143
+ text: str
144
+ offsets: array[int] | None = None
145
+
146
+ def to_original(self, start: int, end: int) -> Span:
147
+ """The original span covering view span [start, end)."""
148
+ if self.offsets is None or start >= end:
149
+ return start, end
150
+ return self.offsets[start], self.offsets[end - 1] + 1
151
+
152
+
153
+ def normalize(text: str) -> NormalizedText:
154
+ """Fold the text so that visually equivalent spellings match the same way.
155
+
156
+ Applies NFKC per character (fullwidth letters and digits, ligatures, NBSP),
157
+ removes format characters (zero-width space and joiner, soft hyphen), maps
158
+ every Unicode dash to "-" and every space separator to " ", and folds
159
+ common Cyrillic and Greek look-alikes to ASCII. Line breaks are kept, so
160
+ line-based detectors see the same lines.
161
+ """
162
+ if text.isascii():
163
+ return NormalizedText(text)
164
+ pieces: list[str] = []
165
+ offsets: array[int] = array("q")
166
+ cursor = 0
167
+ for m in _NON_ASCII_RUN.finditer(text):
168
+ pieces.append(text[cursor:m.start()])
169
+ offsets.extend(range(cursor, m.start()))
170
+ for i in range(m.start(), m.end()):
171
+ folded = _fold_char(text[i])
172
+ pieces.append(folded)
173
+ offsets.extend([i] * len(folded))
174
+ cursor = m.end()
175
+ pieces.append(text[cursor:])
176
+ offsets.extend(range(cursor, len(text)))
177
+ view = "".join(pieces)
178
+ if view == text:
179
+ return NormalizedText(text)
180
+ return NormalizedText(view, offsets)
181
+
182
+
183
+ # --------------------------------------------------------------------------
184
+ # Validators
185
+ # --------------------------------------------------------------------------
186
+
187
+
188
+ def luhn_ok(digits: str) -> bool:
189
+ """Whether a digit string passes the Luhn (mod 10) check."""
190
+ if not digits.isdigit():
191
+ return False
192
+ total = 0
193
+ for i, ch in enumerate(reversed(digits)):
194
+ d = int(ch)
195
+ if i % 2 == 1:
196
+ d *= 2
197
+ if d > 9:
198
+ d -= 9
199
+ total += d
200
+ return total % 10 == 0
201
+
202
+
203
+ def npi_ok(value: str) -> bool:
204
+ """NPI check digit: Luhn over the number prefixed with 80840."""
205
+ return len(value) == 10 and value[0] in "12" and luhn_ok("80840" + value)
206
+
207
+
208
+ def dea_ok(value: str) -> bool:
209
+ """Whether a DEA registration number has a valid check digit."""
210
+ digits = value[2:]
211
+ if len(digits) != 7 or not digits.isdigit():
212
+ return False
213
+ d = [int(c) for c in digits]
214
+ check = (d[0] + d[2] + d[4]) + 2 * (d[1] + d[3] + d[5])
215
+ return check % 10 == d[6]
216
+
217
+
218
+ # SSNs printed in documentation, advertising and training material for decades.
219
+ # They identify nobody and appear in ordinary code and docs, so they are treated
220
+ # as synthetic.
221
+ DUMMY_SSNS: Final = frozenset({
222
+ "078051120", # printed on a wallet insert sold in 1938, later voided
223
+ "219099999", # printed in a Social Security Administration pamphlet
224
+ "123456789", # the sequential example used in forms, documentation and tests
225
+ })
226
+
227
+
228
+ def is_dummy_ssn(digits: str) -> bool:
229
+ """Whether a nine-digit SSN is a well-known example rather than a real one:
230
+ a published example number, or three groups that each repeat one digit
231
+ (111-22-3333)."""
232
+ if digits in DUMMY_SSNS:
233
+ return True
234
+ return all(len(set(part)) == 1 for part in (digits[:3], digits[3:5], digits[5:]))
235
+
236
+
237
+ def ssn_area_ok(area: str, group: str, serial: str) -> bool:
238
+ """Whether an SSN could have been issued, and is not a well-known example."""
239
+ # 000, 666 and 900-999 are never issued. Treat them as synthetic so that
240
+ # test data (Synthea uses 999-xx-xxxx) passes without allowlisting.
241
+ if area in ("000", "666") or area[0] == "9":
242
+ return False
243
+ if group == "00" or serial == "0000":
244
+ return False
245
+ return not is_dummy_ssn(area + group + serial)
246
+
247
+
248
+ # Publicly documented processor test card numbers: synthetic by definition.
249
+ TEST_CARDS: Final = frozenset({
250
+ "4242424242424242",
251
+ "4111111111111111",
252
+ "4012888888881881",
253
+ "4000056655665556",
254
+ "5555555555554444",
255
+ "5105105105105100",
256
+ "2223003122003222",
257
+ "378282246310005",
258
+ "371449635398431",
259
+ "6011111111111117",
260
+ "6011000990139424",
261
+ "3056930009020004",
262
+ "36227206271667",
263
+ "3566002020360505",
264
+ })
265
+ _CARD_PREFIX: Final = re.compile(r"^(4|5[1-5]|2[2-7]|3[47]|6011|65|3[068]|35)")
266
+
267
+
268
+ def card_ok(raw: str) -> bool:
269
+ """Whether a card-shaped number could be a real payment card: issuer
270
+ prefix, length, Luhn, and not a published test number or filler."""
271
+ digits = re.sub(r"[ -]", "", raw)
272
+ if not (13 <= len(digits) <= 19) or digits in TEST_CARDS:
273
+ return False
274
+ if not _CARD_PREFIX.match(digits):
275
+ return False
276
+ if len(set(digits)) <= 2: # 4444 4444 ... style filler
277
+ return False
278
+ return luhn_ok(digits)
279
+
280
+
281
+ TOLL_FREE: Final = frozenset({"800", "833", "844", "855", "866", "877", "888"})
282
+
283
+ SAFE_EMAIL_DOMAINS: Final = re.compile(
284
+ r"(?:^|\.)(?:example\.(?:com|org|net)|example|test|invalid|localhost|"
285
+ r"users\.noreply\.github\.com|noreply\.github\.com)$",
286
+ re.I,
287
+ )
288
+
289
+
290
+ ORG_WORDS: Final = frozenset({
291
+ "inc", "llc", "ltd", "corp", "co", "company", "group", "partners", "labs",
292
+ "health", "healthcare", "care", "services", "solutions", "financial",
293
+ "medical", "clinic", "center", "centre", "hospital", "agency", "therapy",
294
+ "pharmacy", "supply", "systems", "technologies", "foundation", "network",
295
+ })
296
+
297
+
298
+ def not_org_name(value: str) -> bool:
299
+ """False when the last word marks the value as an organisation."""
300
+ last = re.split(r"[\s,]+", value.strip())[-1].lower().strip(".")
301
+ return last not in ORG_WORDS
302
+
303
+
304
+ def has_digit(value: str) -> bool:
305
+ """Whether the value contains at least one digit."""
306
+ return any(ch.isdigit() for ch in value)
307
+
308
+
309
+ # --------------------------------------------------------------------------
310
+ # Finder helpers
311
+ # --------------------------------------------------------------------------
312
+
313
+
314
+ def regex_finder(
315
+ pattern: str,
316
+ flags: int = 0,
317
+ group: int | str = 0,
318
+ validate: Validator | None = None,
319
+ ) -> Finder:
320
+ """A finder yielding the span of `group` for every match that `validate`
321
+ accepts."""
322
+ rx = re.compile(pattern, flags)
323
+
324
+ def find(text: str) -> Iterator[Span]:
325
+ for m in rx.finditer(text):
326
+ if validate is not None and not validate(m):
327
+ continue
328
+ s, e = m.span(group)
329
+ if s < e:
330
+ yield (s, e)
331
+
332
+ return find
333
+
334
+
335
+ # A labelled value may wrap onto the next line right after a separator, as in
336
+ # "SSN: 234-56-" followed by "7891" on the next line.
337
+ BR = r"(?:\r?\n[ \t]*+)?"
338
+ ORDINAL = r"(?:st|nd|rd|th)?"
339
+ MONTHS = r"(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*+\.?"
340
+ DATE = (
341
+ rf"(?:\d{{1,2}}[/.-]{BR}\d{{1,2}}[/.-]{BR}(?:\d{{4}}|\d{{2}})"
342
+ r"|\d{4}-\d{1,2}-\d{1,2}"
343
+ r"|(?:19|20)\d{6}"
344
+ r"|\d{1,2} \d{1,2} (?:19|20)\d{2}"
345
+ rf"|{MONTHS}\s++\d{{1,2}}{ORDINAL},?\s++\d{{4}}"
346
+ rf"|\d{{1,2}}{ORDINAL}(?:\s++|-){MONTHS}(?:,?\s++|-)\d{{4}})"
347
+ )
348
+ # Separator between a label and its value: handles `DOB: x`, `dob = "x"`,
349
+ # `"birthDate": "x"`, `'dob': 'x'`, `DOB#x`, `SSN#: x` and `DOB x`. The value
350
+ # may start on the next line only after a colon or equals sign, so the last
351
+ # column name of a table header is not read as a label for the first cell
352
+ # below it. Every quantifier is possessive, so a long run of spaces after a
353
+ # label is read once.
354
+ SEP = r"""[ \t]*+(?:["'][ \t]*+)?(?:#[ \t]*+)?(?:[:=]\s*+)?(?:["']\s*+)?"""
355
+ # A parenthesised note between a label and its value: `DOB (MM/DD/YYYY): x`.
356
+ NOTE = r"(?:\s*+\([^()\r\n]{0,40}+\))?"
357
+ # Where a label can start. `\b` would miss `patient_dob` (underscore) and
358
+ # `patientDob` (a lower-to-upper camelCase step).
359
+ LB = r"(?:(?<![A-Za-z0-9])|(?-i:(?<=[a-z])(?=[A-Z])))"
360
+ # Between the words of a label: `date of birth`, `date_of_birth`, `dateOfBirth`.
361
+ W = r"[\s_-]*+"
362
+ NUM = r"(?:\#|no\.?|num(?:ber)?)"
363
+ # An XML element used as a label: `<SSN>x`, `<ns:MemberId type="a">x`, and the
364
+ # FHIR attribute form `<birthDate value="x"/>`.
365
+ XML_OPEN = r"<(?:[A-Za-z][\w.-]{0,40}:)?"
366
+ XML_CLOSE = r"""(?:\s[^<>]{0,300}?\bvalue\s*+=\s*+["']|(?:\s[^<>]{0,300}+)?>\s*+)"""
367
+
368
+
369
+ def labelled(label: str, value: str) -> str:
370
+ """A pattern for `value` after `label`, as a key, a field or an XML
371
+ element. The value is the named group `v`."""
372
+ return rf"(?:{XML_OPEN}(?:{label}){XML_CLOSE}|{LB}(?:{label}){NOTE}{SEP})(?P<v>{value})"
373
+
374
+
375
+ _XML_OPEN_BEFORE: Final = re.compile(XML_OPEN + r"\Z")
376
+
377
+
378
+ def labelled_finder(
379
+ label: str, value: str, starts: str, validate: Validator | None = None, ignore_case: bool = True
380
+ ) -> Finder:
381
+ """A finder for `labelled(label, value)` that yields the span of the value.
382
+
383
+ `starts` lists every letter a spelling of the label can begin with. The
384
+ full pattern is tried only where the label itself occurs (or at the `<`
385
+ opening an XML element around it): scanning for the label behind a
386
+ one-character lookahead is several times cheaper than trying the label
387
+ boundary and the XML form at every position.
388
+ """
389
+ flags = "(?i)" if ignore_case else ""
390
+ rx = re.compile(flags + labelled(label, value))
391
+ trigger = re.compile(f"{flags}(?=[{starts.lower()}{starts.upper()}])(?:{label})")
392
+
393
+ def find(text: str) -> Iterator[Span]:
394
+ end = 0
395
+ for hit in trigger.finditer(text):
396
+ p = hit.start()
397
+ if p < end:
398
+ continue
399
+ m = None
400
+ if p and text[p - 1] in "<:":
401
+ xml = _XML_OPEN_BEFORE.search(text, max(end, p - 42), p)
402
+ if xml is not None:
403
+ m = rx.match(text, xml.start())
404
+ if m is None:
405
+ m = rx.match(text, p)
406
+ if m is None:
407
+ continue
408
+ end = m.end()
409
+ if validate is not None and not validate(m):
410
+ continue
411
+ s, e = m.span("v")
412
+ if s < e:
413
+ yield (s, e)
414
+
415
+ return find
416
+
417
+
418
+ NAME = r"[A-Z][a-z'\-]++(?:\s++[A-Z]\.?)?(?:\s++[A-Z][a-z'\-]++){1,2}|[A-Z][a-z'\-]++,\s*+[A-Z][a-z'\-]++"
419
+ # In a field that names a patient or member, also capitals (`JOHN SMITH`), the
420
+ # `SMITH, JOHN` order and names such as `McDonald`. Words stay on one line.
421
+ _NAME_WORD = r"[A-Z][A-Za-z'\-]++"
422
+ PATIENT_NAME = (
423
+ rf"{_NAME_WORD}(?:[ \t]++[A-Z]\.?)?(?:[ \t]++{_NAME_WORD}){{1,2}}|{_NAME_WORD},[ \t]*+{_NAME_WORD}"
424
+ )
425
+
426
+ US_STATES = (
427
+ "AL|AK|AZ|AR|CA|CO|CT|DE|DC|FL|GA|HI|ID|IL|IN|IA|KS|KY|LA|ME|MD|MA|MI|MN|MS|"
428
+ "MO|MT|NE|NV|NH|NJ|NM|NY|NC|ND|OH|OK|OR|PA|RI|SC|SD|TN|TX|UT|VT|VA|WA|WV|WI|WY|PR"
429
+ )
430
+ STREET_SUFFIX = (
431
+ "Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Lane|Ln|Drive|Dr|Court|Ct|Way|"
432
+ "Place|Pl|Terrace|Ter|Parkway|Pkwy|Circle|Cir|Highway|Hwy"
433
+ )
434
+ # A capitalised word, or one in capitals as on mailing labels.
435
+ _PLACE_WORD = r"(?:[A-Z][a-z]++|[A-Z]{2,}+)"
436
+
437
+ MBI_L = "AC-HJKMNP-RT-Y" # letters allowed in an MBI (no S, L, O, I, B, Z)
438
+
439
+
440
+ def _ssn_valid(m: re.Match[str]) -> bool:
441
+ return ssn_area_ok(m.group("a"), m.group("g"), m.group("s"))
442
+
443
+
444
+ def _ssn_bare_valid(m: re.Match[str]) -> bool:
445
+ v = re.sub(r"\D", "", m.group("v"))
446
+ return ssn_area_ok(v[:3], v[3:5], v[5:])
447
+
448
+
449
+ def _mbi_valid(m: re.Match[str]) -> bool:
450
+ """One case throughout. A random mixed-case token is far more often an
451
+ opaque id than an MBI typed in lowercase."""
452
+ letters = [c for c in m.group(0) if c.isalpha()]
453
+ return all(c.isupper() for c in letters) or all(c.islower() for c in letters)
454
+
455
+
456
+ def _phone_valid(m: re.Match[str]) -> bool:
457
+ area = m.group("area1") or m.group("area2")
458
+ exch, line = m.group("exch"), m.group("line")
459
+ if area in TOLL_FREE:
460
+ return False
461
+ if exch == "555" and 100 <= int(line) <= 199: # reserved fictional range
462
+ return False
463
+ return True
464
+
465
+
466
+ def _email_valid(m: re.Match[str]) -> bool:
467
+ return not SAFE_EMAIL_DOMAINS.search(m.group("domain"))
468
+
469
+
470
+ def _ip_valid(m: re.Match[str]) -> bool:
471
+ try:
472
+ ip = ipaddress.ip_address(m.group(0))
473
+ except ValueError:
474
+ return False
475
+ return ip.is_global
476
+
477
+
478
+ def _card_valid(m: re.Match[str]) -> bool:
479
+ return card_ok(m.group(0))
480
+
481
+
482
+ def _npi_valid(m: re.Match[str]) -> bool:
483
+ return npi_ok(m.group(0))
484
+
485
+
486
+ def _dea_valid(m: re.Match[str]) -> bool:
487
+ return dea_ok(m.group(0))
488
+
489
+
490
+ def _person_valid(m: re.Match[str]) -> bool:
491
+ return not_org_name(m.group("v"))
492
+
493
+
494
+ def _x12_ref_valid(m: re.Match[str]) -> bool:
495
+ return has_digit(m.group("v"))
496
+
497
+
498
+ # Column types and encodings that follow an id in schemas and code, such as
499
+ # `patient_id INT8`. They contain digits but they are not identifiers.
500
+ _TYPE_NAME = re.compile(r"(?i)^(?:u?int|float|serial|char|varchar|sha|md|utf|base|uuid|bytea)-?\d*$")
501
+
502
+
503
+ def _id_value(name: str, min_length: int = 4) -> Validator:
504
+ """A validator for an identifier in group `name`: at least `min_length`
505
+ letters and digits, at least one digit, and not a type name."""
506
+
507
+ def valid(m: re.Match[str]) -> bool:
508
+ value = m.group(name)
509
+ compact = value.replace(" ", "")
510
+ return len(compact) >= min_length and has_digit(value) and not _TYPE_NAME.match(compact)
511
+
512
+ return valid
513
+
514
+
515
+ # --------------------------------------------------------------------------
516
+ # Delimited tables
517
+ # --------------------------------------------------------------------------
518
+
519
+ MAX_ROW_LINES: Final = 50 # how far a quoted cell may run across lines
520
+
521
+ _DIRECT_COLUMNS: Final = frozenset({
522
+ "social_security_number", "social_security", "ss_number",
523
+ "date_of_birth", "birth_date", "birthdate",
524
+ "medical_record_number", "medical_record_no", "chart_number",
525
+ "member_id", "member_number", "member_name", "subscriber_id", "subscriber_name",
526
+ "medicaid_id", "medicare_id", "medicaid_number", "medicare_number",
527
+ "insurance_id", "policy_number",
528
+ "patient_name", "patient_full_name", "client_name", "beneficiary_name",
529
+ "insured_name", "patient_id", "client_id_number",
530
+ })
531
+ # Any column with one of these words, such as `patient_dob` or `ssn_last4`.
532
+ _DIRECT_TOKENS: Final = frozenset({"ssn", "dob", "mrn", "mbi", "hicn"})
533
+ # A column naming the person a row is about, combined with an identifier or name
534
+ # word: `Mbr_ID`, `Subscriber No`, `Insured ID`, `Patient Acct #`.
535
+ _SUBJECT_TOKENS: Final = frozenset({
536
+ "member", "mbr", "subscriber", "insured", "patient", "pt", "medicaid", "medicare", "beneficiary",
537
+ })
538
+ _ID_TOKENS: Final = frozenset({"id", "no", "num", "number", "nbr", "nr", "identifier", "name"})
539
+ FIRST_NAME_COLUMNS: Final = frozenset({
540
+ "first_name", "firstname", "fname", "given_name", "patient_first_name", "first", "given",
541
+ })
542
+ LAST_NAME_COLUMNS: Final = frozenset({
543
+ "last_name", "lastname", "lname", "family_name", "surname", "patient_last_name", "last", "family",
544
+ })
545
+ QUASI_COLUMNS: Final = frozenset({
546
+ "diagnosis", "dx", "icd10", "icd_10", "icd10_code", "diagnosis_code",
547
+ "address", "street", "street_address", "address1", "address_line_1",
548
+ "phone", "phone_number", "mobile", "email", "zip", "zipcode", "zip_code",
549
+ "date_of_service", "dos", "admit_date", "admission_date", "discharge_date",
550
+ })
551
+
552
+ _CAMEL_STEP: Final = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
553
+ _PARENTHESISED: Final = re.compile(r"\([^()]*\)")
554
+
555
+
556
+ def normalize_column(col: str) -> str:
557
+ """A header cell as a snake_case column name.
558
+
559
+ `DOB (mm/dd/yyyy)` gives `dob`, `[Patient Name]` gives `patient_name`,
560
+ `MemberID` gives `member_id` and `Member #` gives `member_no`.
561
+ """
562
+ col = col.strip().strip("\"'").strip()
563
+ if col.endswith(":"):
564
+ col = col[:-1].rstrip()
565
+ if col.startswith("[") and col.endswith("]"):
566
+ col = col[1:-1]
567
+ col = _PARENTHESISED.sub("", col).replace("#", " no ")
568
+ col = _CAMEL_STEP.sub("_", col).lower()
569
+ return re.sub(r"[^a-z0-9]+", "_", col).strip("_")
570
+
571
+
572
+ def cell_spans(row: str, delim: str) -> list[Span]:
573
+ """(start, end) of each cell. A delimiter inside double quotes is data."""
574
+ spans = []
575
+ start = 0
576
+ if '"' not in row:
577
+ for piece in row.split(delim):
578
+ spans.append((start, start + len(piece)))
579
+ start += len(piece) + len(delim)
580
+ return spans
581
+ quoted = False
582
+ for i, ch in enumerate(row):
583
+ if ch == '"':
584
+ quoted = not quoted
585
+ elif ch == delim and not quoted:
586
+ spans.append((start, i))
587
+ start = i + 1
588
+ spans.append((start, len(row)))
589
+ return spans
590
+
591
+
592
+ def header_delimiter(row: str) -> str | None:
593
+ """The first of comma, tab, pipe and semicolon that the row contains."""
594
+ return next((d for d in (",", "\t", "|", ";") if d in row), None)
595
+
596
+
597
+ def logical_rows(lines: list[str], start: int, stop: int) -> Iterator[Span]:
598
+ """(first, last) line indexes of each row. A quoted cell can span lines, so
599
+ a line with an unbalanced quote continues on the next one."""
600
+ i = start
601
+ while i < stop:
602
+ last = i
603
+ open_quote = lines[i].count('"') % 2 == 1
604
+ while open_quote and last + 1 < stop and last - i < MAX_ROW_LINES:
605
+ last += 1
606
+ open_quote ^= lines[last].count('"') % 2 == 1
607
+ yield i, last
608
+ i = last + 1
609
+
610
+
611
+ _RULE_ROW: Final = re.compile(r"^[\s|:+\-=]*$")
612
+ _COLUMN_NAME: Final = re.compile(r"^[a-z][a-z0-9_]{0,40}$")
613
+ _PLACEHOLDER: Final = re.compile(r"^\[[A-Z0-9_]+_\d+\]$")
614
+ _LIST_ITEM: Final = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s")
615
+ _PERSON_NAME: Final = re.compile(rf"(?:{NAME})$")
616
+ # A tuple, or a line of a list of strings. Not any trailing comma: spreadsheets
617
+ # and many real exports end every row with a delimiter.
618
+ _CODE_LINE: Final = re.compile(r"""^\s*\(|^\s*["'][^"']*["'],\s.*,\s*$""")
619
+ # Quick rejections, so that ordinary lines of code never reach the cell logic.
620
+ _NOT_A_HEADER: Final = re.compile(r"[={}<>]")
621
+ # A colon other than at the end of a cell, or a subscript: code, not a header.
622
+ _CODE_PUNCTUATION: Final = re.compile(r"""\w\[|\]\w|:(?![ \t]*+(?:[,\t|;"']|$))""")
623
+ _HEADER_WORD: Final = re.compile(
624
+ r"(?i)(?=[bcdhimnps])(?:ssn|dob|mrn|mbi|hicn|name|patient|birth|member|mbr|subscriber|medic|insur|polic|chart"
625
+ r"|social|client|benefic)"
626
+ )
627
+
628
+
629
+ def is_rule_row(row: str) -> bool:
630
+ """Whether a row is a separator such as `|---|---|`, not data."""
631
+ return bool(_RULE_ROW.match(row))
632
+
633
+
634
+ def is_direct_column(col: str) -> bool:
635
+ """Whether a normalised column name holds an identifier on its own."""
636
+ tokens = set(col.split("_"))
637
+ if col in _DIRECT_COLUMNS or tokens & _DIRECT_TOKENS:
638
+ return True
639
+ return bool(tokens & _SUBJECT_TOKENS) and bool(tokens & _ID_TOKENS)
640
+
641
+
642
+ def _header_cell_ok(cell: str) -> bool:
643
+ """A cell that can be a column name: brackets only around the whole name,
644
+ a colon only at its end, and balanced parentheses."""
645
+ c = cell.strip().strip("\"'").strip()
646
+ if c.endswith(":"):
647
+ c = c[:-1].rstrip()
648
+ if _PLACEHOLDER.match(c): # a redacted value, not a column name
649
+ return False
650
+ if c.startswith("[") and c.endswith("]"):
651
+ c = c[1:-1]
652
+ if any(ch in c for ch in "[]:"):
653
+ return False
654
+ return not any(ch in _PARENTHESISED.sub("", c) for ch in "()")
655
+
656
+
657
+ def _is_header(raw: str, cells: list[str]) -> bool:
658
+ """Whether a row reads as column names rather than prose with commas.
659
+ Two columns are weak evidence, so a table that narrow must not contain a
660
+ multi-word cell that is not a known column."""
661
+ if len(cells) < 2 or _LIST_ITEM.match(raw) or _CODE_LINE.search(raw):
662
+ return False
663
+ if not all(_header_cell_ok(c) for c in cells):
664
+ return False
665
+ names = [normalize_column(c) for c in cells]
666
+ if not all(_COLUMN_NAME.match(c) for c in names) or any(len(c.split()) > 4 for c in cells):
667
+ return False
668
+ return len(names) > 2 or all(is_direct_column(n) or len(c.split()) == 1 for n, c in zip(names, cells, strict=True))
669
+
670
+
671
+ @dataclass(frozen=True)
672
+ class TableHeader:
673
+ """The layout a header line gives the rows below it."""
674
+
675
+ delimiter: str
676
+ columns: tuple[str, ...] # normalised names, one per cell, empty cells included
677
+
678
+ @property
679
+ def identifier_columns(self) -> list[int]:
680
+ """Indexes of columns that identify the person a row is about."""
681
+ return [
682
+ k for k, c in enumerate(self.columns)
683
+ if is_direct_column(c) or c in FIRST_NAME_COLUMNS or c in LAST_NAME_COLUMNS
684
+ ]
685
+
686
+
687
+ def parse_header(raw: str) -> TableHeader | None:
688
+ """The header a line declares, when it names patient identifier columns."""
689
+ delim = header_delimiter(raw)
690
+ if delim is None or _NOT_A_HEADER.search(raw) or not _HEADER_WORD.search(raw) or _CODE_PUNCTUATION.search(raw):
691
+ return None
692
+ raw_cells = [raw[a:b] for a, b in cell_spans(raw, delim)]
693
+ if not _is_header(raw, [c for c in raw_cells if c.strip()]):
694
+ return None
695
+ columns = tuple(normalize_column(c) for c in raw_cells)
696
+ cols = set(columns)
697
+ direct = any(is_direct_column(c) for c in cols)
698
+ name_pair = bool(cols & FIRST_NAME_COLUMNS) and bool(cols & LAST_NAME_COLUMNS)
699
+ if not (direct or (name_pair and cols & QUASI_COLUMNS)):
700
+ return None
701
+ return TableHeader(delim, columns)
702
+
703
+
704
+ @dataclass(frozen=True)
705
+ class Table:
706
+ """A delimited table: its header and the offsets of its rows.
707
+
708
+ `start` and `end` bound the body, from the line after the header. `rows` are the (start, end) offsets of
709
+ each logical row, without its line terminator; blank lines are not rows.
710
+ """
711
+
712
+ header: TableHeader
713
+ start: int
714
+ end: int
715
+ rows: tuple[Span, ...]
716
+
717
+
718
+ def _live_row(row: str, delim: str, id_cols: list[int], name_cols: set[int]) -> bool:
719
+ """A data row whose identifier cells hold real values, not placeholders."""
720
+ if _RULE_ROW.match(row):
721
+ return False
722
+ parts = [row[a:b].strip(" \t\"'") for a, b in cell_spans(row, delim)]
723
+ values = [(k, parts[k]) for k in id_cols if k < len(parts)]
724
+ if not any(v and not _PLACEHOLDER.match(v) for _, v in values):
725
+ return False
726
+ # A table of pure text can still be a patient list: its digit-free rows
727
+ # count when a name column holds what looks like a person's name. Digits
728
+ # inside placeholders are not data.
729
+ return any(has_digit(p) for p in parts if not _PLACEHOLDER.match(p)) or any(
730
+ k in name_cols and _PERSON_NAME.match(v) for k, v in values
731
+ )
732
+
733
+
734
+ def _continues(line: str, header: TableHeader) -> bool:
735
+ """Whether a row after a blank line still belongs to the table above: it
736
+ has the header's width and does not start a table of its own."""
737
+ raw = line.rstrip("\r\n")
738
+ width = len(header.columns)
739
+ if len(cell_spans(raw, header.delimiter)) != width or parse_header(raw) is not None:
740
+ return False
741
+ return width > 2 or has_digit(raw)
742
+
743
+
744
+ def _blocks(lines: list[str], header_line: int, header: TableHeader) -> list[Span]:
745
+ """(first, stop) line ranges of the table's row blocks. Rows run to a blank
746
+ line; after a single blank line, rows of the header's width continue the
747
+ table (an export with a blank line after its header, or between pages)."""
748
+ n = len(lines)
749
+ blocks: list[Span] = []
750
+ pos = header_line + 1
751
+ while pos < n:
752
+ k = pos
753
+ while k < n and not lines[k].strip() and k - pos < 2:
754
+ k += 1
755
+ gap = k - pos
756
+ if k >= n or gap > 1 or not lines[k].strip():
757
+ break
758
+ if gap == 1 and not _continues(lines[k], header):
759
+ break
760
+ if blocks and gap == 0:
761
+ break
762
+ stop = k
763
+ while stop < n and lines[stop].strip():
764
+ stop += 1
765
+ blocks.append((k, stop))
766
+ pos = stop
767
+ return blocks
768
+
769
+
770
+ def find_tables(text: str, live_only: bool = True) -> Iterator[Table]:
771
+ """CSV/TSV/pipe tables whose header names patient identifiers. With
772
+ `live_only`, only tables with at least one data row holding a value.
773
+ Rows of the wrong width stay in the table, so redaction can deal with
774
+ them."""
775
+ lines = text.splitlines(keepends=True)
776
+ offsets = []
777
+ pos = 0
778
+ for ln in lines:
779
+ offsets.append(pos)
780
+ pos += len(ln)
781
+ i = 0
782
+ n = len(lines)
783
+ while i < n:
784
+ header = parse_header(lines[i].rstrip("\r\n"))
785
+ if header is None:
786
+ i += 1
787
+ continue
788
+ blocks = _blocks(lines, i, header)
789
+ if not blocks:
790
+ i += 1
791
+ continue
792
+ id_cols = header.identifier_columns
793
+ name_cols = {k for k in id_cols if "name" in header.columns[k].split("_")}
794
+ # A wide header is rare enough in code to trust on its own, so a
795
+ # malformed row in that table still counts. A narrow one is not: `dob,
796
+ # mrn` could be a line of a program, so its rows must line up.
797
+ width = len(header.columns)
798
+ rows: list[Span] = []
799
+ live = not live_only
800
+ for first, stop in blocks:
801
+ for a, b in logical_rows(lines, first, stop):
802
+ row = "".join(lines[a:b + 1]).rstrip("\r\n")
803
+ rows.append((offsets[a], offsets[a] + len(row)))
804
+ if not live:
805
+ lines_up = width > 3 or len(cell_spans(row, header.delimiter)) == width
806
+ live = lines_up and _live_row(row, header.delimiter, id_cols, name_cols)
807
+ if live:
808
+ # The body starts on the line after the header, a blank one included,
809
+ # so that removing that blank line changes the finding.
810
+ yield Table(header, offsets[i + 1], rows[-1][1], tuple(rows))
811
+ # The rows are consumed either way, so input of repeated header-like
812
+ # lines stays linear.
813
+ i = max(blocks[-1][1], i + 1)
814
+
815
+
816
+ def tabular_finder(text: str) -> Iterator[Span]:
817
+ """The body of every live patient table (see `find_tables`)."""
818
+ for table in find_tables(text):
819
+ yield table.start, table.end
820
+
821
+
822
+ # --------------------------------------------------------------------------
823
+ # FHIR
824
+ # --------------------------------------------------------------------------
825
+
826
+ _FHIR_WINDOW: Final = 3000
827
+ _FHIR_PATIENT: Final = re.compile(r'"resourceType"\s*+:\s*+"Patient"')
828
+ _FHIR_EVIDENCE: Final = re.compile(
829
+ r'"birthDate"\s*+:\s*+"\d|"family"\s*+:\s*+"[A-Za-z]|"given"\s*+:\s*+\[\s*+"[A-Za-z]'
830
+ r'|"name"\s*+:\s*+\[\s*+\{[^\]]{0,500}?"text"\s*+:\s*+"[A-Za-z]'
831
+ r'|"system"\s*+:\s*+"[^"]{0,200}?us-ssn'
832
+ )
833
+ _FHIR_XML_PATIENT: Final = re.compile(r"<(?:[A-Za-z][\w.-]{0,40}:)?Patient(?:\s[^<>]{0,500}+)?>")
834
+ _FHIR_XML_EVIDENCE: Final = re.compile(
835
+ r"""<(?:[A-Za-z][\w.-]{0,40}:)?(?:birthDate\s++value\s*+=\s*+["']\d|(?:family|given|text)\s++value\s*+=\s*+["'][A-Za-z])"""
836
+ r"|us-ssn"
837
+ )
838
+
839
+
840
+ def _anchored_finder(anchor: re.Pattern[str], evidence: re.Pattern[str]) -> Finder:
841
+ """Spans of `anchor` with a match of `evidence` starting within the window
842
+ around it. Evidence is located once, so many anchors stay linear."""
843
+
844
+ def find(text: str) -> Iterator[Span]:
845
+ marks: list[int] | None = None
846
+ for m in anchor.finditer(text):
847
+ if marks is None:
848
+ marks = [e.start() for e in evidence.finditer(text)]
849
+ k = bisect.bisect_left(marks, m.start() - _FHIR_WINDOW)
850
+ if k < len(marks) and marks[k] <= m.end() + _FHIR_WINDOW:
851
+ yield m.span()
852
+
853
+ return find
854
+
855
+
856
+ fhir_patient_finder = _anchored_finder(_FHIR_PATIENT, _FHIR_EVIDENCE)
857
+ fhir_xml_patient_finder = _anchored_finder(_FHIR_XML_PATIENT, _FHIR_XML_EVIDENCE)
858
+
859
+
860
+ # --------------------------------------------------------------------------
861
+ # Labels
862
+ # --------------------------------------------------------------------------
863
+
864
+ SSN_LABEL = rf"ssn|ss{W}{NUM}|social{W}security(?:{W}{NUM})?|tin"
865
+ SSN_VALUE = rf"\d{{3}}(?:[ .-]{BR})?\d{{2}}(?:[ .-]{BR})?\d{{4}}(?![\d-])"
866
+ MRN_LABEL = (
867
+ rf"mrn|mr{W}{NUM}|medical{W}record(?:{W}{NUM})?|p(?:atien)?t{W}(?:id(?:entifier)?|{NUM})"
868
+ rf"|chart{W}{NUM}|encounter{W}(?:id|{NUM})"
869
+ )
870
+ MRN_VALUE = r"[A-Z0-9][A-Z0-9-]{3,19}\b"
871
+ MEMBER_LABEL = (
872
+ rf"(?:member|mbr|subscriber|beneficiary|policy{W}holder|insurance|insured|medicaid|medicare)"
873
+ rf"{W}(?:id(?:entifier)?|{NUM})|policy{W}{NUM}|hicn|hic{W}{NUM}"
874
+ )
875
+ # An identifier, optionally printed in space-separated groups (`XYZ 1234 5678`).
876
+ # Groups after the first are capitals and digits with at least one digit, so a
877
+ # following word of prose does not join the value.
878
+ MEMBER_VALUE = r"[A-Z0-9][A-Z0-9-]{1,19}(?-i:(?:[ ](?=[A-Z0-9]{0,9}\d)[A-Z0-9]{2,10}\b){0,3})\b"
879
+ DOB_LABEL = rf"dob|d\.o\.b\.?|date{W}of{W}birth|birth{W}(?:date|day)|born(?:\s++on)?"
880
+ PATIENT_NAME_LABEL = (
881
+ r"(?i:patient|pt|client|member|beneficiary|resident|insured|subscriber|guarantor)(?:'s)?"
882
+ rf"{W}(?i:full{W})?(?i:name)"
883
+ )
884
+ ACCOUNT_LABEL = rf"(?:account|acct|bank{W}account|iban){W}{NUM}"
885
+ ACCOUNT_VALUE = r"\d[\d -]{5,24}\d"
886
+ CLINICAL_DATE_LABEL = (
887
+ rf"date{W}of{W}service|dos|admi(?:t|ssion){W}date|discharge{W}date|date{W}of{W}death|dod"
888
+ )
889
+ DIAGNOSIS_LABEL = rf"(?:dx|diagnosis|diag|icd{W}10(?:{W}cm)?)(?:{W}codes?)?"
890
+ ICD10 = r"(?-i:[A-TV-Z]\d[0-9AB](?:\.?[0-9A-TV-Z]{1,4})?)\b"
891
+
892
+ # HL7 v2: real feeds separate segments with CR; logs and JSON often carry the
893
+ # separator as a literal `\r` or `\n` escape, and samples are often indented.
894
+ _HL7_CHAR = r"(?:[^\r\n\\]|\\(?![rn]))"
895
+ _HL7_FIELD_CHAR = r"(?:[^\r\n\\^]|\\(?![rn]))"
896
+
897
+
898
+ # --------------------------------------------------------------------------
899
+ # Registry
900
+ # --------------------------------------------------------------------------
901
+
902
+ DETECTORS: list[Detector] = [
903
+ Detector(
904
+ "ssn", HIGH, DIRECT, "US Social Security number (issued ranges only)",
905
+ regex_finder(r"(?<![\d-])(?P<a>\d{3})-(?P<g>\d{2})-(?P<s>\d{4})(?![\d-])", validate=_ssn_valid),
906
+ ),
907
+ Detector(
908
+ "ssn", HIGH, DIRECT, "Labelled SSN or TIN (bare, spaced or dotted digits)",
909
+ labelled_finder(SSN_LABEL, SSN_VALUE, "st", _ssn_bare_valid),
910
+ ("ss", "social", "tin"),
911
+ ),
912
+ Detector(
913
+ "medicare_mbi", HIGH, DIRECT, "Medicare Beneficiary Identifier",
914
+ regex_finder(
915
+ rf"(?i)\b[1-9][{MBI_L}][{MBI_L}0-9]\d[- ]?[{MBI_L}][{MBI_L}0-9]\d[- ]?[{MBI_L}]{{2}}\d{{2}}\b",
916
+ validate=_mbi_valid,
917
+ ),
918
+ ),
919
+ Detector(
920
+ "mrn", HIGH, DIRECT, "Labelled medical record, patient, chart or encounter number",
921
+ labelled_finder(MRN_LABEL, MRN_VALUE, "mpce", _id_value("v")),
922
+ ("mr", "medical", "pt", "patient", "chart", "encounter"),
923
+ ),
924
+ Detector(
925
+ "member_id", HIGH, DIRECT, "Labelled health plan member / subscriber / Medicaid / Medicare ID",
926
+ labelled_finder(MEMBER_LABEL, MEMBER_VALUE, "msbpih", _id_value("v", 5)),
927
+ ("member", "mbr", "subscriber", "beneficiary", "policy", "insur", "medicaid", "medicare", "hic"),
928
+ ),
929
+ Detector(
930
+ "dob", HIGH, DIRECT, "Labelled date of birth",
931
+ labelled_finder(DOB_LABEL, DATE, "db"),
932
+ ("dob", "d.o.b", "birth", "born"),
933
+ ),
934
+ Detector(
935
+ "account_number", HIGH, DIRECT, "Labelled financial account number",
936
+ labelled_finder(ACCOUNT_LABEL, ACCOUNT_VALUE, "abi"),
937
+ ("account", "acct", "iban"),
938
+ ),
939
+ Detector(
940
+ "payment_card", HIGH, DIRECT, "Payment card number (Luhn-valid, test cards excluded)",
941
+ # Unbroken, or grouped the way cards are printed (4-4-4-4, 4-6-5). The
942
+ # guards skip the fractional digits of a float.
943
+ regex_finder(
944
+ r"(?<![\w.-])(?:\d{13,19}|\d{4}([ -])\d{4}\1\d{4}\1\d{1,7}|\d{4}([ -])\d{6}\2\d{5})(?![\w-]|\.\d)",
945
+ validate=_card_valid,
946
+ ),
947
+ ),
948
+ Detector(
949
+ "x12_patient", HIGH, DIRECT, "X12 EDI patient, subscriber or dependent name segment (NM1*QC / IL / 03)",
950
+ regex_finder(r"(?i)NM1(?P<d>[*|^])(?:QC|IL|03)(?P=d)1(?P=d)[^~\r\n]++"),
951
+ ("nm1",),
952
+ ),
953
+ Detector(
954
+ "x12_patient", HIGH, DIRECT, "X12 EDI reference segment with a person's identifier (REF*SY / 0F / 1L / 1W / EA)",
955
+ regex_finder(
956
+ r"(?i)REF(?P<d>[*|^])(?:SY|0F|1L|1W|EA)(?P=d)(?P<v>[^~\r\n*|^]{2,50}+)",
957
+ validate=_x12_ref_valid,
958
+ ),
959
+ ("ref",),
960
+ ),
961
+ Detector(
962
+ "x12_dob", HIGH, DIRECT, "X12 EDI demographic segment with birth date (DMG*D8)",
963
+ regex_finder(r"(?i)DMG(?P<d>[*|^])D8(?P=d)\d{8}"),
964
+ ("dmg",),
965
+ ),
966
+ Detector(
967
+ "hl7_pid", HIGH, DIRECT, "HL7 v2 PID segment with a patient name",
968
+ regex_finder(
969
+ rf"(?:(?<![^\r\n])[ \t]*+|(?<=\\[rn]))(?P<v>PID\|{_HL7_FIELD_CHAR}{{0,300}}+\^{_HL7_CHAR}*+)",
970
+ group="v",
971
+ ),
972
+ ("pid|",),
973
+ ),
974
+ Detector(
975
+ "fhir_patient", HIGH, DIRECT, "FHIR Patient resource with name, birthDate or SSN", fhir_patient_finder,
976
+ ('"resourcetype"',),
977
+ ),
978
+ Detector(
979
+ "fhir_patient", HIGH, DIRECT, "FHIR Patient resource in XML with name, birthDate or SSN",
980
+ fhir_xml_patient_finder,
981
+ ("patient",),
982
+ ),
983
+ Detector("tabular_phi", HIGH, DIRECT, "Delimited table with patient identifier columns and data rows", tabular_finder),
984
+ Detector(
985
+ "person_name", HIGH, PERSON, "Patient/member/client name in a labelled field",
986
+ labelled_finder(PATIENT_NAME_LABEL, PATIENT_NAME, "pcmbrisg", _person_valid, ignore_case=False),
987
+ ("name",),
988
+ ),
989
+ Detector(
990
+ "person_name", MEDIUM, PERSON, "Generic labelled full name",
991
+ regex_finder(r"\b(?i:full\s*+_?name|name)" + SEP + rf"(?P<v>{NAME})", group="v", validate=_person_valid),
992
+ ("name",),
993
+ ),
994
+ Detector(
995
+ "diagnosis", MEDIUM, CLINICAL, "Labelled ICD-10 diagnosis code",
996
+ labelled_finder(DIAGNOSIS_LABEL, ICD10, "di"),
997
+ ("dx", "diag", "icd"),
998
+ ),
999
+ Detector(
1000
+ "clinical_date", MEDIUM, CLINICAL, "Labelled admission, discharge, service or death date",
1001
+ labelled_finder(CLINICAL_DATE_LABEL, DATE, "da"),
1002
+ ("service", "dos", "admi", "discharge", "death", "dod"),
1003
+ ),
1004
+ Detector(
1005
+ "phone", MEDIUM, CONTACT, "US phone number (toll-free and 555-01xx excluded)",
1006
+ regex_finder(
1007
+ r"(?<![\w.-])(?:\+?1[\s.-]?)?(?:\((?P<area1>[2-9]\d{2})\)\s?|(?P<area2>[2-9]\d{2})[.-])"
1008
+ r"(?P<exch>[2-9]\d{2})[.-](?P<line>\d{4})(?![\w-])",
1009
+ validate=_phone_valid,
1010
+ ),
1011
+ ),
1012
+ Detector(
1013
+ "email", MEDIUM, CONTACT, "Email address (example/test domains excluded)",
1014
+ regex_finder(
1015
+ r"(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]{1,64}@(?P<domain>[A-Za-z0-9-]++(?:\.[A-Za-z0-9-]++)*\.[A-Za-z]{2,}+)\b",
1016
+ validate=_email_valid,
1017
+ ),
1018
+ ),
1019
+ Detector(
1020
+ "street_address", MEDIUM, CONTACT, "Street address",
1021
+ regex_finder(
1022
+ rf"\b\d{{1,6}}\s++(?:[NSEW]\.?\s++)?(?:{_PLACE_WORD}\s++){{1,3}}(?:{STREET_SUFFIX}|{STREET_SUFFIX.upper()})\b\.?"
1023
+ r"(?:,?\s++(?:Apt|Unit|Suite|Ste|APT|UNIT|SUITE|STE|#)\.?\s*+[\w-]+)?"
1024
+ ),
1025
+ ),
1026
+ Detector(
1027
+ "city_state_zip", MEDIUM, CONTACT, "City, state and ZIP code",
1028
+ regex_finder(rf"\b{_PLACE_WORD}(?:\s{_PLACE_WORD}){{0,3}},\s*+(?:{US_STATES})\s++\d{{5}}(?:-\d{{4}})?\b"),
1029
+ ),
1030
+ Detector(
1031
+ "provider_npi", LOW, PROVIDER, "National Provider Identifier (check digit valid)",
1032
+ regex_finder(r"(?<![\d-])[12]\d{9}(?![\d-])", validate=_npi_valid),
1033
+ ),
1034
+ Detector(
1035
+ "provider_dea", LOW, PROVIDER, "DEA registration number (checksum valid)",
1036
+ regex_finder(r"\b[ABCDEFGHJKLMPRSTUX][A-Z9]\d{7}\b", validate=_dea_valid),
1037
+ ),
1038
+ Detector(
1039
+ "ip_address", LOW, NETWORK, "Public IPv4 address",
1040
+ regex_finder(r"(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])", validate=_ip_valid),
1041
+ ),
1042
+ ]
1043
+
1044
+ CATEGORIES = sorted({d.name for d in DETECTORS})
1045
+
1046
+
1047
+ def _sort_key(f: Finding) -> tuple[int, int]:
1048
+ return f.start, -SEVERITY_RANK[f.severity]
1049
+
1050
+
1051
+ def detect(text: str, disabled: set[str] | frozenset[str] = frozenset()) -> list[Finding]:
1052
+ """Run every enabled detector over `text` exactly as given, without
1053
+ normalising it. Spans are offsets into `text`."""
1054
+ out: list[Finding] = []
1055
+ seen: set[tuple[str, int, int]] = set()
1056
+ lowered = text.lower()
1057
+ for det in DETECTORS:
1058
+ if det.name in disabled or (det.hints and not any(hint in lowered for hint in det.hints)):
1059
+ continue
1060
+ for s, e in det.find(text):
1061
+ key = (det.name, s, e)
1062
+ if key in seen:
1063
+ continue
1064
+ seen.add(key)
1065
+ out.append(Finding(det.name, det.severity, det.group, s, e))
1066
+ out.sort(key=_sort_key)
1067
+ return out
1068
+
1069
+
1070
+ def run_detectors(text: str, disabled: set[str] | frozenset[str] = frozenset()) -> list[Finding]:
1071
+ """Run every enabled detector over the normalised view of `text`. Spans are
1072
+ offsets into the original text. Line and column are filled in by the
1073
+ scanner."""
1074
+ view = normalize(text)
1075
+ findings = detect(view.text, disabled)
1076
+ if view.offsets is None:
1077
+ return findings
1078
+ out: list[Finding] = []
1079
+ seen: set[tuple[str, int, int]] = set()
1080
+ for f in findings:
1081
+ s, e = view.to_original(f.start, f.end)
1082
+ if (f.category, s, e) not in seen:
1083
+ seen.add((f.category, s, e))
1084
+ out.append(Finding(f.category, f.severity, f.group, s, e))
1085
+ out.sort(key=_sort_key)
1086
+ return out