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/__init__.py +3 -0
- phi_gate/__main__.py +5 -0
- phi_gate/audit.py +330 -0
- phi_gate/cli.py +410 -0
- phi_gate/config.py +600 -0
- phi_gate/detectors.py +1086 -0
- phi_gate/globs.py +227 -0
- phi_gate/hooks.py +644 -0
- phi_gate/policy.py +97 -0
- phi_gate/py.typed +0 -0
- phi_gate/redact.py +236 -0
- phi_gate/scanner.py +776 -0
- phi_gate/shell.py +1582 -0
- phi_gate-0.2.0.dist-info/METADATA +344 -0
- phi_gate-0.2.0.dist-info/RECORD +19 -0
- phi_gate-0.2.0.dist-info/WHEEL +5 -0
- phi_gate-0.2.0.dist-info/entry_points.txt +2 -0
- phi_gate-0.2.0.dist-info/licenses/LICENSE +21 -0
- phi_gate-0.2.0.dist-info/top_level.txt +1 -0
phi_gate/policy.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Turn findings into a decision: allow, warn or block.
|
|
2
|
+
|
|
3
|
+
Rules, in order:
|
|
4
|
+
1. Any HIGH finding blocks. These identify a person on their own.
|
|
5
|
+
2. A clinical fact (a labelled diagnosis code, a date of service) blocks
|
|
6
|
+
when an identifier (a labelled name, phone, email, address) sits within
|
|
7
|
+
`record_window` characters. PHI is identity plus health information:
|
|
8
|
+
"Jane Doe ... Dx: F84.0" is a patient record.
|
|
9
|
+
3. Other MEDIUM findings warn. A name next to an email is a package.json
|
|
10
|
+
author field or a staff directory, not PHI by itself.
|
|
11
|
+
4. LOW findings (provider NPI/DEA, public IPs) are reported, never blocking.
|
|
12
|
+
|
|
13
|
+
A change to a file (a commit) is judged by what it adds, read in the context
|
|
14
|
+
of the whole file: `changed` says which findings the change touched. Only
|
|
15
|
+
those block or warn, so a file already holding PHI that was allowed in before
|
|
16
|
+
does not block every later edit; but a record pair counts when either side is
|
|
17
|
+
new, because a diagnosis added next to a patient's name makes a record even
|
|
18
|
+
though the name was there already.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from collections import Counter
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
|
|
27
|
+
from .detectors import CLINICAL, CONTACT, HIGH, LOW, MEDIUM, PERSON, Finding
|
|
28
|
+
|
|
29
|
+
ALLOW, WARN, BLOCK = "allow", "warn", "block"
|
|
30
|
+
IDENTITY_GROUPS = (PERSON, CONTACT)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Verdict:
|
|
35
|
+
decision: str
|
|
36
|
+
findings: list[Finding] = field(default_factory=list)
|
|
37
|
+
reasons: list[str] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def blocking(self) -> bool:
|
|
41
|
+
return self.decision == BLOCK
|
|
42
|
+
|
|
43
|
+
def counts(self, include_low: bool = False) -> Counter:
|
|
44
|
+
return Counter(
|
|
45
|
+
f.category for f in self.findings if include_low or f.severity != LOW
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def summary(self, include_low: bool = False) -> str:
|
|
49
|
+
c = self.counts(include_low)
|
|
50
|
+
return ", ".join(f"{k} x{v}" for k, v in sorted(c.items())) or "none"
|
|
51
|
+
|
|
52
|
+
def lines(self, limit: int = 8) -> str:
|
|
53
|
+
nums = sorted({f.line for f in self.findings if f.severity != LOW and f.line})
|
|
54
|
+
if not nums:
|
|
55
|
+
return ""
|
|
56
|
+
shown = ", ".join(str(n) for n in nums[:limit])
|
|
57
|
+
more = f" (+{len(nums) - limit} more)" if len(nums) > limit else ""
|
|
58
|
+
label = "line" if len(nums) == 1 else "lines"
|
|
59
|
+
return f"{label} {shown}{more}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def evaluate(
|
|
63
|
+
findings: list[Finding],
|
|
64
|
+
record_window: int = 400,
|
|
65
|
+
changed: Callable[[Finding], bool] | None = None,
|
|
66
|
+
) -> Verdict:
|
|
67
|
+
"""The decision for `findings`. With `changed`, only the findings it
|
|
68
|
+
accepts are judged, and the rest are context for the record rule."""
|
|
69
|
+
new = findings if changed is None else [f for f in findings if changed(f)]
|
|
70
|
+
reasons: list[str] = []
|
|
71
|
+
high = [f for f in new if f.severity == HIGH]
|
|
72
|
+
if high:
|
|
73
|
+
reasons.append("direct identifiers: " + ", ".join(sorted({f.category for f in high})))
|
|
74
|
+
return Verdict(BLOCK, new, reasons)
|
|
75
|
+
|
|
76
|
+
medium = sorted((f for f in findings if f.severity == MEDIUM), key=lambda f: f.start)
|
|
77
|
+
# An identifier a clinical fact can pair with. A HIGH one has blocked on its
|
|
78
|
+
# own already unless it was in the file before the change, and then it is
|
|
79
|
+
# the strongest identifier a new diagnosis could sit beside.
|
|
80
|
+
identities = [f for f in findings if f.severity == HIGH or (f.severity == MEDIUM and f.group in IDENTITY_GROUPS)]
|
|
81
|
+
fresh = {id(f) for f in new}
|
|
82
|
+
for anchor in medium:
|
|
83
|
+
if anchor.group != CLINICAL:
|
|
84
|
+
continue
|
|
85
|
+
for other in identities:
|
|
86
|
+
if id(anchor) not in fresh and id(other) not in fresh:
|
|
87
|
+
continue
|
|
88
|
+
gap = max(other.start - anchor.end, anchor.start - other.end, 0)
|
|
89
|
+
if gap <= record_window:
|
|
90
|
+
reasons.append(f"record-like: {anchor.category} near {other.category}")
|
|
91
|
+
pair = [f for f in (anchor, other) if id(f) not in fresh]
|
|
92
|
+
return Verdict(BLOCK, new + pair, reasons)
|
|
93
|
+
|
|
94
|
+
if any(f.severity == MEDIUM for f in new):
|
|
95
|
+
reasons.append("possible identifiers: " + ", ".join(sorted({f.category for f in new if f.severity == MEDIUM})))
|
|
96
|
+
return Verdict(WARN, new, reasons)
|
|
97
|
+
return Verdict(ALLOW, new, reasons)
|
phi_gate/py.typed
ADDED
|
File without changes
|
phi_gate/redact.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Reversible, typed redaction.
|
|
2
|
+
|
|
3
|
+
`redact("DOB: <a date>, SSN <an SSN>")` returns
|
|
4
|
+
`"DOB: [DOB_1], SSN [SSN_1]"` plus a mapping from placeholder to original
|
|
5
|
+
value. The same value always gets the same placeholder within one call, so
|
|
6
|
+
relationships survive ("[PERSON_NAME_1] called back about [MRN_1]").
|
|
7
|
+
|
|
8
|
+
A placeholder is never one that the input already contains as literal text:
|
|
9
|
+
numbering skips any `[NAME_N]` already present. `restore` substitutes only the
|
|
10
|
+
placeholders in its mapping, in one pass, so it cannot write a value where
|
|
11
|
+
the document had only text.
|
|
12
|
+
|
|
13
|
+
The mapping contains the original PHI. Keep it local and never commit it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import bisect
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
from .config import Config
|
|
22
|
+
from .detectors import (
|
|
23
|
+
DIRECT,
|
|
24
|
+
FIRST_NAME_COLUMNS,
|
|
25
|
+
HIGH,
|
|
26
|
+
LAST_NAME_COLUMNS,
|
|
27
|
+
LOW,
|
|
28
|
+
QUASI_COLUMNS,
|
|
29
|
+
SEVERITY_RANK,
|
|
30
|
+
Finding,
|
|
31
|
+
Table,
|
|
32
|
+
cell_spans,
|
|
33
|
+
detect,
|
|
34
|
+
find_tables,
|
|
35
|
+
is_direct_column,
|
|
36
|
+
is_rule_row,
|
|
37
|
+
normalize,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Columns that describe care or an organisation rather than a person. Safe
|
|
41
|
+
# Harbor removes identifiers; this content can stay, which is what keeps a
|
|
42
|
+
# redacted export useful ("group denials by diagnosis").
|
|
43
|
+
_KEEP_COLUMNS = frozenset({"diagnosis", "dx", "icd10", "icd_10", "icd10_code", "diagnosis_code"})
|
|
44
|
+
_KEEP_TOKENS = frozenset({"payer", "plan", "provider", "facility", "clinic", "carrier", "organization"})
|
|
45
|
+
|
|
46
|
+
# Words that mark a column as identifying someone. This list errs on the side
|
|
47
|
+
# of redacting: the cost of a false match is one less useful column.
|
|
48
|
+
_IDENTIFIER_TOKENS = frozenset({
|
|
49
|
+
"name", "first", "last", "given", "family", "surname", "patient", "pt", "client", "member", "mbr",
|
|
50
|
+
"subscriber", "insured", "beneficiary", "guarantor", "contact", "address", "address1", "address2",
|
|
51
|
+
"street", "city", "zip", "zipcode", "postal", "phone", "mobile", "fax", "email", "ssn", "dob", "mrn",
|
|
52
|
+
"mbi", "hicn", "birth", "birthdate", "account", "acct", "policy", "medicaid", "medicare", "claim",
|
|
53
|
+
"encounter", "note", "notes", "comment", "comments", "memo", "narrative",
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
_PLACEHOLDER_TOKEN = re.compile(r"\[[A-Z][A-Z0-9_]*_\d+\]")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_identifier_column(col: str) -> bool:
|
|
60
|
+
"""Whether redaction removes a column's values. A column that identifies
|
|
61
|
+
someone on its own always goes, even when it also names a payer or plan."""
|
|
62
|
+
if is_direct_column(col):
|
|
63
|
+
return True
|
|
64
|
+
if col in _KEEP_COLUMNS:
|
|
65
|
+
return False
|
|
66
|
+
tokens = set(col.split("_"))
|
|
67
|
+
if tokens & _KEEP_TOKENS:
|
|
68
|
+
return False
|
|
69
|
+
return col in FIRST_NAME_COLUMNS or col in LAST_NAME_COLUMNS or col in QUASI_COLUMNS or bool(tokens & _IDENTIFIER_TOKENS)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _value_bounds(text: str, a: int, b: int) -> tuple[int, int]:
|
|
73
|
+
"""A cell's value without its padding or quotes, so they survive."""
|
|
74
|
+
while a < b and text[a] in ' \t"':
|
|
75
|
+
a += 1
|
|
76
|
+
while b > a and text[b - 1] in ' \t"':
|
|
77
|
+
b -= 1
|
|
78
|
+
return a, b
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _cell_finding(text: str, a: int, b: int, category: str) -> Finding | None:
|
|
82
|
+
"""A finding for a cell's value, or None for an empty cell."""
|
|
83
|
+
a, b = _value_bounds(text, a, b)
|
|
84
|
+
return Finding(category, HIGH, DIRECT, a, b) if a < b else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# A table row redacted column by column: its offsets and its cells' offsets.
|
|
88
|
+
_Row = tuple[int, int, list[tuple[int, int]]]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _table_cells(text: str, table: Table) -> tuple[list[Finding], list[_Row]]:
|
|
92
|
+
"""Turn one table into per-cell findings for identifier columns, so the
|
|
93
|
+
redacted table keeps its shape and its non-identifying columns. A row that
|
|
94
|
+
does not line up with the header cannot be trusted column by column, so
|
|
95
|
+
all of it goes. Also returns the rows handled cell by cell."""
|
|
96
|
+
cols = table.header.columns
|
|
97
|
+
targets = [i for i, c in enumerate(cols) if is_identifier_column(c)]
|
|
98
|
+
if not targets:
|
|
99
|
+
return [Finding("tabular_phi", HIGH, DIRECT, table.start, table.end)], []
|
|
100
|
+
cells: list[Finding] = []
|
|
101
|
+
rows: list[_Row] = []
|
|
102
|
+
for start, end in table.rows:
|
|
103
|
+
row = text[start:end]
|
|
104
|
+
if is_rule_row(row):
|
|
105
|
+
continue
|
|
106
|
+
spans = cell_spans(row, table.header.delimiter)
|
|
107
|
+
if len(spans) != len(cols):
|
|
108
|
+
found = _cell_finding(text, start, end, "row")
|
|
109
|
+
cells += [found] if found else []
|
|
110
|
+
continue
|
|
111
|
+
rows.append((start, end, [(start + a, start + b) for a, b in spans]))
|
|
112
|
+
for idx in targets:
|
|
113
|
+
a, b = spans[idx]
|
|
114
|
+
found = _cell_finding(text, start + a, start + b, cols[idx])
|
|
115
|
+
cells += [found] if found else []
|
|
116
|
+
return cells, rows
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _piece(text: str, f: Finding, a: int, b: int) -> list[Finding]:
|
|
120
|
+
"""The part of `f` in [a, b) without surrounding whitespace, if any."""
|
|
121
|
+
while a < b and text[a].isspace():
|
|
122
|
+
a += 1
|
|
123
|
+
while b > a and text[b - 1].isspace():
|
|
124
|
+
b -= 1
|
|
125
|
+
return [Finding(f.category, f.severity, f.group, a, b)] if a < b else []
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _within_cells(text: str, f: Finding, rows: list[_Row]) -> list[Finding]:
|
|
129
|
+
"""A finding that overlaps table rows, cut at the cell boundaries: every
|
|
130
|
+
cell it touches goes whole, and the delimiters and line breaks stay, so
|
|
131
|
+
each row keeps its width and the rest of its columns stay readable."""
|
|
132
|
+
k = max(bisect.bisect_right(rows, (f.start, float("inf"))) - 1, 0)
|
|
133
|
+
out: list[Finding] = []
|
|
134
|
+
cursor = f.start
|
|
135
|
+
while k < len(rows) and rows[k][0] < f.end:
|
|
136
|
+
start, end, cells = rows[k]
|
|
137
|
+
k += 1
|
|
138
|
+
if end <= f.start:
|
|
139
|
+
continue
|
|
140
|
+
out += _piece(text, f, cursor, start)
|
|
141
|
+
for a, b in cells:
|
|
142
|
+
if a < f.end and f.start < b:
|
|
143
|
+
out += _piece(text, f, *_value_bounds(text, a, b))
|
|
144
|
+
cursor = max(cursor, end)
|
|
145
|
+
return out + _piece(text, f, cursor, f.end)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _select(findings: list[Finding], include_low: bool) -> list[Finding]:
|
|
149
|
+
"""Non-overlapping spans to replace. Overlapping findings merge into one
|
|
150
|
+
span covering both, so no part of either value is left behind; it takes
|
|
151
|
+
the category of the wider finding, or the more severe one on a tie."""
|
|
152
|
+
chosen: list[Finding] = []
|
|
153
|
+
for f in sorted(findings, key=lambda f: (f.start, -(f.end - f.start), -SEVERITY_RANK[f.severity])):
|
|
154
|
+
if f.severity == LOW and not include_low:
|
|
155
|
+
continue
|
|
156
|
+
if chosen and f.start < chosen[-1].end:
|
|
157
|
+
prev = chosen[-1]
|
|
158
|
+
wider = (f.end - f.start, SEVERITY_RANK[f.severity]) > (prev.end - prev.start, SEVERITY_RANK[prev.severity])
|
|
159
|
+
kind = f if wider else prev
|
|
160
|
+
chosen[-1] = Finding(kind.category, kind.severity, kind.group, prev.start, max(prev.end, f.end))
|
|
161
|
+
continue
|
|
162
|
+
chosen.append(f)
|
|
163
|
+
return chosen
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _findings(text: str, cfg: Config | None) -> list[Finding]:
|
|
167
|
+
"""Findings in original-text offsets, with tables split into cells."""
|
|
168
|
+
disabled = frozenset(cfg.disable) if cfg else frozenset()
|
|
169
|
+
view = normalize(text)
|
|
170
|
+
found = detect(view.text, disabled)
|
|
171
|
+
tables = {(t.start, t.end): t for t in find_tables(view.text)} if any(
|
|
172
|
+
f.category == "tabular_phi" for f in found
|
|
173
|
+
) else {}
|
|
174
|
+
kept = []
|
|
175
|
+
for f in found:
|
|
176
|
+
s, e = view.to_original(f.start, f.end)
|
|
177
|
+
if not (cfg and cfg.allow_values and cfg.is_allowed_value(text[s:e])):
|
|
178
|
+
kept.append(f)
|
|
179
|
+
parts: list[Finding] = []
|
|
180
|
+
rows: list[_Row] = []
|
|
181
|
+
for f in kept:
|
|
182
|
+
table = tables.get((f.start, f.end)) if f.category == "tabular_phi" else None
|
|
183
|
+
if table is not None:
|
|
184
|
+
cells, table_rows = _table_cells(view.text, table)
|
|
185
|
+
parts += cells
|
|
186
|
+
rows += table_rows
|
|
187
|
+
rows.sort()
|
|
188
|
+
for f in kept:
|
|
189
|
+
if f.category == "tabular_phi" and (f.start, f.end) in tables:
|
|
190
|
+
continue
|
|
191
|
+
parts += _within_cells(view.text, f, rows) if rows else [f]
|
|
192
|
+
out: list[Finding] = []
|
|
193
|
+
for part in parts:
|
|
194
|
+
ps, pe = view.to_original(part.start, part.end)
|
|
195
|
+
out.append(Finding(part.category, part.severity, part.group, ps, pe))
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def redact(text: str, cfg: Config | None = None, include_low: bool = False) -> tuple[str, dict[str, str]]:
|
|
200
|
+
"""A copy of `text` with every finding replaced by a typed placeholder,
|
|
201
|
+
and the mapping from placeholder to original value. LOW findings stay
|
|
202
|
+
unless `include_low`."""
|
|
203
|
+
chosen = _select(_findings(text, cfg), include_low)
|
|
204
|
+
literal = set(_PLACEHOLDER_TOKEN.findall(text))
|
|
205
|
+
|
|
206
|
+
by_value: dict[tuple[str, str], str] = {}
|
|
207
|
+
counters: dict[str, int] = {}
|
|
208
|
+
mapping: dict[str, str] = {}
|
|
209
|
+
out: list[str] = []
|
|
210
|
+
cursor = 0
|
|
211
|
+
for f in chosen:
|
|
212
|
+
value = text[f.start:f.end]
|
|
213
|
+
key = (f.category, value)
|
|
214
|
+
if key not in by_value:
|
|
215
|
+
label = f.category.upper()
|
|
216
|
+
placeholder = ""
|
|
217
|
+
while not placeholder or placeholder in literal:
|
|
218
|
+
counters[label] = counters.get(label, 0) + 1
|
|
219
|
+
placeholder = f"[{label}_{counters[label]}]"
|
|
220
|
+
by_value[key] = placeholder
|
|
221
|
+
mapping[placeholder] = value
|
|
222
|
+
out.append(text[cursor:f.start])
|
|
223
|
+
out.append(by_value[key])
|
|
224
|
+
cursor = f.end
|
|
225
|
+
out.append(text[cursor:])
|
|
226
|
+
return "".join(out), mapping
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def restore(text: str, mapping: dict[str, str]) -> str:
|
|
230
|
+
"""Put the original values back. Only the placeholders in `mapping` are
|
|
231
|
+
replaced, in a single pass, so a restored value that happens to contain
|
|
232
|
+
placeholder-like text is never substituted again."""
|
|
233
|
+
if not mapping:
|
|
234
|
+
return text
|
|
235
|
+
pattern = re.compile("|".join(re.escape(p) for p in sorted(mapping, key=len, reverse=True)))
|
|
236
|
+
return pattern.sub(lambda m: mapping[m.group(0)], text)
|