python-hwpx 2.17.0__py3-none-any.whl → 2.18.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.
- hwpx/tools/exporter.py +46 -11
- hwpx/tools/mail_merge.py +10 -0
- hwpx/tools/pii.py +389 -0
- {python_hwpx-2.17.0.dist-info → python_hwpx-2.18.0.dist-info}/METADATA +1 -1
- {python_hwpx-2.17.0.dist-info → python_hwpx-2.18.0.dist-info}/RECORD +10 -9
- {python_hwpx-2.17.0.dist-info → python_hwpx-2.18.0.dist-info}/WHEEL +0 -0
- {python_hwpx-2.17.0.dist-info → python_hwpx-2.18.0.dist-info}/entry_points.txt +0 -0
- {python_hwpx-2.17.0.dist-info → python_hwpx-2.18.0.dist-info}/licenses/LICENSE +0 -0
- {python_hwpx-2.17.0.dist-info → python_hwpx-2.18.0.dist-info}/licenses/NOTICE +0 -0
- {python_hwpx-2.17.0.dist-info → python_hwpx-2.18.0.dist-info}/top_level.txt +0 -0
hwpx/tools/exporter.py
CHANGED
|
@@ -14,6 +14,7 @@ from xml.etree import ElementTree as ET
|
|
|
14
14
|
from zipfile import ZipFile
|
|
15
15
|
|
|
16
16
|
from ..opc.security import guard_zip_file, parse_xml_stdlib
|
|
17
|
+
from .pii import PIIPolicy, mask_pii
|
|
17
18
|
|
|
18
19
|
if TYPE_CHECKING:
|
|
19
20
|
from ..document import HwpxDocument
|
|
@@ -64,7 +65,18 @@ def _paragraph_text(p: ET.Element, *, tab_token: str = "\t") -> str:
|
|
|
64
65
|
return "".join(parts)
|
|
65
66
|
|
|
66
67
|
|
|
67
|
-
def
|
|
68
|
+
def _mask_text(text: str, masking_policy: "PIIPolicy | None") -> str:
|
|
69
|
+
if masking_policy is None:
|
|
70
|
+
return text
|
|
71
|
+
return mask_pii(text, masking_policy)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _table_cells_text(
|
|
75
|
+
tbl: ET.Element,
|
|
76
|
+
*,
|
|
77
|
+
tab_token: str = "\t",
|
|
78
|
+
masking_policy: "PIIPolicy | None" = None,
|
|
79
|
+
) -> list[list[str]]:
|
|
68
80
|
"""Return a row-major 2D list of cell texts from a table element."""
|
|
69
81
|
rows: list[list[str]] = []
|
|
70
82
|
for tr in tbl.findall(f"{_HP}tr"):
|
|
@@ -75,7 +87,7 @@ def _table_cells_text(tbl: ET.Element, *, tab_token: str = "\t") -> list[list[st
|
|
|
75
87
|
text = _paragraph_text(paragraph, tab_token=tab_token)
|
|
76
88
|
if text:
|
|
77
89
|
cell_parts.append(text)
|
|
78
|
-
row.append("\n".join(cell_parts).strip())
|
|
90
|
+
row.append(_mask_text("\n".join(cell_parts).strip(), masking_policy))
|
|
79
91
|
rows.append(row)
|
|
80
92
|
return rows
|
|
81
93
|
|
|
@@ -84,19 +96,27 @@ def _find_tables(p: ET.Element) -> list[ET.Element]:
|
|
|
84
96
|
return p.findall(f".//{_HP}tbl")
|
|
85
97
|
|
|
86
98
|
|
|
87
|
-
def export_text(
|
|
99
|
+
def export_text(
|
|
100
|
+
source: HwpxDocument | bytes,
|
|
101
|
+
*,
|
|
102
|
+
paragraph_separator: str = "\n",
|
|
103
|
+
section_separator: str = "\n\n",
|
|
104
|
+
include_tables: bool = True,
|
|
105
|
+
tab_token: str = "\t",
|
|
106
|
+
masking_policy: "PIIPolicy | None" = None,
|
|
107
|
+
) -> str:
|
|
88
108
|
"""Export document content as plain text."""
|
|
89
109
|
sections = _section_xmls(source)
|
|
90
110
|
section_texts: list[str] = []
|
|
91
111
|
for section_root in sections:
|
|
92
112
|
para_texts: list[str] = []
|
|
93
113
|
for p in _iter_paragraphs(section_root):
|
|
94
|
-
text = _paragraph_text(p, tab_token=tab_token)
|
|
114
|
+
text = _mask_text(_paragraph_text(p, tab_token=tab_token), masking_policy)
|
|
95
115
|
if text:
|
|
96
116
|
para_texts.append(text)
|
|
97
117
|
if include_tables:
|
|
98
118
|
for tbl in _find_tables(p):
|
|
99
|
-
rows = _table_cells_text(tbl, tab_token=tab_token)
|
|
119
|
+
rows = _table_cells_text(tbl, tab_token=tab_token, masking_policy=masking_policy)
|
|
100
120
|
for row in rows:
|
|
101
121
|
para_texts.append(tab_token.join(row))
|
|
102
122
|
section_texts.append(paragraph_separator.join(para_texts))
|
|
@@ -107,7 +127,15 @@ def _escape_html(text: str) -> str:
|
|
|
107
127
|
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
108
128
|
|
|
109
129
|
|
|
110
|
-
def export_html(
|
|
130
|
+
def export_html(
|
|
131
|
+
source: HwpxDocument | bytes,
|
|
132
|
+
*,
|
|
133
|
+
include_tables: bool = True,
|
|
134
|
+
full_document: bool = True,
|
|
135
|
+
title: str = "HWPX Document",
|
|
136
|
+
tab_token: str = "\t",
|
|
137
|
+
masking_policy: "PIIPolicy | None" = None,
|
|
138
|
+
) -> str:
|
|
111
139
|
"""Export document content as HTML."""
|
|
112
140
|
sections = _section_xmls(source)
|
|
113
141
|
body_parts: list[str] = []
|
|
@@ -115,12 +143,12 @@ def export_html(source: HwpxDocument | bytes, *, include_tables: bool = True, fu
|
|
|
115
143
|
if sec_idx > 0:
|
|
116
144
|
body_parts.append("<hr />")
|
|
117
145
|
for p in _iter_paragraphs(section_root):
|
|
118
|
-
text = _paragraph_text(p, tab_token=tab_token)
|
|
146
|
+
text = _mask_text(_paragraph_text(p, tab_token=tab_token), masking_policy)
|
|
119
147
|
if text:
|
|
120
148
|
body_parts.append(f"<p>{_escape_html(text)}</p>")
|
|
121
149
|
if include_tables:
|
|
122
150
|
for tbl in _find_tables(p):
|
|
123
|
-
rows = _table_cells_text(tbl, tab_token=tab_token)
|
|
151
|
+
rows = _table_cells_text(tbl, tab_token=tab_token, masking_policy=masking_policy)
|
|
124
152
|
if rows:
|
|
125
153
|
body_parts.append('<table border="1">')
|
|
126
154
|
for row in rows:
|
|
@@ -146,20 +174,27 @@ def export_html(source: HwpxDocument | bytes, *, include_tables: bool = True, fu
|
|
|
146
174
|
return body
|
|
147
175
|
|
|
148
176
|
|
|
149
|
-
def export_markdown(
|
|
177
|
+
def export_markdown(
|
|
178
|
+
source: HwpxDocument | bytes,
|
|
179
|
+
*,
|
|
180
|
+
include_tables: bool = True,
|
|
181
|
+
section_separator: str = "\n---\n\n",
|
|
182
|
+
tab_token: str = "\t",
|
|
183
|
+
masking_policy: "PIIPolicy | None" = None,
|
|
184
|
+
) -> str:
|
|
150
185
|
"""Export document content as Markdown."""
|
|
151
186
|
sections = _section_xmls(source)
|
|
152
187
|
section_parts: list[str] = []
|
|
153
188
|
for section_root in sections:
|
|
154
189
|
lines: list[str] = []
|
|
155
190
|
for p in _iter_paragraphs(section_root):
|
|
156
|
-
text = _paragraph_text(p, tab_token=tab_token)
|
|
191
|
+
text = _mask_text(_paragraph_text(p, tab_token=tab_token), masking_policy)
|
|
157
192
|
if text:
|
|
158
193
|
lines.append(text)
|
|
159
194
|
lines.append("")
|
|
160
195
|
if include_tables:
|
|
161
196
|
for tbl in _find_tables(p):
|
|
162
|
-
rows = _table_cells_text(tbl, tab_token=tab_token)
|
|
197
|
+
rows = _table_cells_text(tbl, tab_token=tab_token, masking_policy=masking_policy)
|
|
163
198
|
if rows:
|
|
164
199
|
header = rows[0]
|
|
165
200
|
lines.append("| " + " | ".join(header) + " |")
|
hwpx/tools/mail_merge.py
CHANGED
|
@@ -14,6 +14,7 @@ from zipfile import ZIP_DEFLATED, ZipFile
|
|
|
14
14
|
from ..document import HwpxDocument
|
|
15
15
|
from .exporter import export_text
|
|
16
16
|
from .package_validator import validate_editor_open_safety
|
|
17
|
+
from .pii import DEFAULT_POLICY, PIIPolicy, mask_pii
|
|
17
18
|
|
|
18
19
|
MAIL_MERGE_REPORT_VERSION = "mail-merge-v1"
|
|
19
20
|
|
|
@@ -180,6 +181,7 @@ def mail_merge(
|
|
|
180
181
|
split_newlines: bool = True,
|
|
181
182
|
fit_policy: "Any | None" = None,
|
|
182
183
|
max_lines: int = 1,
|
|
184
|
+
masking_policy: "PIIPolicy | None" = DEFAULT_POLICY,
|
|
183
185
|
) -> dict[str, Any]:
|
|
184
186
|
"""Generate one HWPX per row from a placeholder template.
|
|
185
187
|
|
|
@@ -237,6 +239,7 @@ def mail_merge(
|
|
|
237
239
|
"unresolvedPlaceholders": [item["token"] for item in placeholders if item["key"] in missing_keys],
|
|
238
240
|
"openSafety": None,
|
|
239
241
|
"fitFields": [],
|
|
242
|
+
"maskedFields": [],
|
|
240
243
|
"reasons": ["missing_required"],
|
|
241
244
|
"ok": False,
|
|
242
245
|
}
|
|
@@ -247,11 +250,17 @@ def mail_merge(
|
|
|
247
250
|
row_cells = _iter_cells(document)
|
|
248
251
|
replaced_count = 0
|
|
249
252
|
fit_fields: list[dict[str, Any]] = []
|
|
253
|
+
masked_fields: list[str] = []
|
|
250
254
|
try:
|
|
251
255
|
for placeholder in placeholders:
|
|
252
256
|
key = str(placeholder["key"])
|
|
253
257
|
token = str(placeholder["token"])
|
|
254
258
|
value = "" if _is_missing(row.get(key)) else str(row.get(key))
|
|
259
|
+
if masking_policy is not None:
|
|
260
|
+
masked_value = mask_pii(value, masking_policy)
|
|
261
|
+
if masked_value != value and key not in masked_fields:
|
|
262
|
+
masked_fields.append(key)
|
|
263
|
+
value = masked_value
|
|
255
264
|
if not split_newlines:
|
|
256
265
|
value = value.replace("\r\n", " ").replace("\n", " ")
|
|
257
266
|
apply_value = value
|
|
@@ -287,6 +296,7 @@ def mail_merge(
|
|
|
287
296
|
"unresolvedPlaceholders": unresolved,
|
|
288
297
|
"openSafety": open_safety,
|
|
289
298
|
"fitFields": fit_fields,
|
|
299
|
+
"maskedFields": masked_fields,
|
|
290
300
|
"reasons": reasons,
|
|
291
301
|
"ok": row_ok,
|
|
292
302
|
}
|
hwpx/tools/pii.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""PII detection, masking, and structural privacy helpers.
|
|
3
|
+
|
|
4
|
+
The machine-checkable set is intentionally always enabled by default. Contextual
|
|
5
|
+
patterns are label-gated and reported as low-confidence to avoid broad free-text
|
|
6
|
+
over-masking.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Iterable, Mapping, Sized
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Literal, TypedDict
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
Confidence = Literal["high", "low"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PIISpan(TypedDict):
|
|
22
|
+
type: str
|
|
23
|
+
start: int
|
|
24
|
+
end: int
|
|
25
|
+
value: str
|
|
26
|
+
confidence: Confidence
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class PIIPolicy:
|
|
31
|
+
"""Controls PII detection and masking behavior."""
|
|
32
|
+
|
|
33
|
+
mask_char: str = "*"
|
|
34
|
+
machine_enabled: bool = True
|
|
35
|
+
contextual_enabled: bool = True
|
|
36
|
+
prefix_len: int = 1
|
|
37
|
+
suffix_len: int = 0
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
if len(self.mask_char) != 1:
|
|
41
|
+
raise ValueError("mask_char must be exactly one character")
|
|
42
|
+
if self.prefix_len < 0 or self.suffix_len < 0:
|
|
43
|
+
raise ValueError("prefix_len and suffix_len must be non-negative")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
DEFAULT_POLICY = PIIPolicy()
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"Confidence",
|
|
50
|
+
"DEFAULT_POLICY",
|
|
51
|
+
"PIIPolicy",
|
|
52
|
+
"PIISpan",
|
|
53
|
+
"PiiLogFilter",
|
|
54
|
+
"Pseudonymizer",
|
|
55
|
+
"deidentify",
|
|
56
|
+
"detect_pii",
|
|
57
|
+
"mask_pii",
|
|
58
|
+
"mask_value",
|
|
59
|
+
"minimize_fields",
|
|
60
|
+
"scrub_exception_message",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
_RRN_RE = re.compile(r"(?<!\d)(?P<front>\d{6})-?(?P<gender>[1-4])(?P<rear>\d{6})(?!\d)")
|
|
64
|
+
_PHONE_RE = re.compile(
|
|
65
|
+
r"(?<!\d)(?P<prefix>010|011|016|017|018|019)-?(?P<middle>\d{3,4})-?(?P<last>\d{4})(?!\d)"
|
|
66
|
+
)
|
|
67
|
+
_EMAIL_RE = re.compile(
|
|
68
|
+
r"(?<![A-Za-z0-9.!#$%&'*+/=?^_`{|}~-])"
|
|
69
|
+
r"(?P<local>[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+)"
|
|
70
|
+
r"@"
|
|
71
|
+
r"(?P<domain>[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
|
|
72
|
+
r"(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+)"
|
|
73
|
+
r"(?![A-Za-z0-9-])"
|
|
74
|
+
)
|
|
75
|
+
_CARD_RE = re.compile(r"(?<!\d)(?:\d{4}[- ]?){3}\d{4}(?!\d)")
|
|
76
|
+
|
|
77
|
+
_ACCOUNT_RE = re.compile(
|
|
78
|
+
r"(?:계좌(?:번호)?|통장(?:번호)?)[^\d\n]{0,12}(?P<value>\d[\d-]{8,20}\d)"
|
|
79
|
+
)
|
|
80
|
+
_REGIONS = (
|
|
81
|
+
"서울특별시",
|
|
82
|
+
"부산광역시",
|
|
83
|
+
"대구광역시",
|
|
84
|
+
"인천광역시",
|
|
85
|
+
"광주광역시",
|
|
86
|
+
"대전광역시",
|
|
87
|
+
"울산광역시",
|
|
88
|
+
"세종특별자치시",
|
|
89
|
+
"경기도",
|
|
90
|
+
"강원도",
|
|
91
|
+
"충청북도",
|
|
92
|
+
"충청남도",
|
|
93
|
+
"전라북도",
|
|
94
|
+
"전라남도",
|
|
95
|
+
"경상북도",
|
|
96
|
+
"경상남도",
|
|
97
|
+
"제주특별자치도",
|
|
98
|
+
"서울",
|
|
99
|
+
"부산",
|
|
100
|
+
"대구",
|
|
101
|
+
"인천",
|
|
102
|
+
"광주",
|
|
103
|
+
"대전",
|
|
104
|
+
"울산",
|
|
105
|
+
"세종",
|
|
106
|
+
"경기",
|
|
107
|
+
"강원",
|
|
108
|
+
"충북",
|
|
109
|
+
"충남",
|
|
110
|
+
"전북",
|
|
111
|
+
"전남",
|
|
112
|
+
"경북",
|
|
113
|
+
"경남",
|
|
114
|
+
"제주",
|
|
115
|
+
)
|
|
116
|
+
_REGION_RE = "|".join(re.escape(region) for region in _REGIONS)
|
|
117
|
+
_ADDRESS_RE = re.compile(
|
|
118
|
+
rf"(?:주소|도로명주소|소재지|거주지)\s*[::]?\s*(?P<value>(?:{_REGION_RE})[^\n,;]{{2,80}})"
|
|
119
|
+
)
|
|
120
|
+
_NAME_RE = re.compile(r"(?:성명|수신자|이름)\s*[::]\s*(?P<value>[가-힣]{2,4})")
|
|
121
|
+
|
|
122
|
+
_TYPE_ALIASES = {
|
|
123
|
+
"resident_registration_number": "rrn",
|
|
124
|
+
"registration_number": "rrn",
|
|
125
|
+
"jumin": "rrn",
|
|
126
|
+
"주민등록번호": "rrn",
|
|
127
|
+
"휴대폰": "phone",
|
|
128
|
+
"전화번호": "phone",
|
|
129
|
+
"mobile": "phone",
|
|
130
|
+
"email_address": "email",
|
|
131
|
+
"이메일": "email",
|
|
132
|
+
"card_number": "card",
|
|
133
|
+
"credit_card": "card",
|
|
134
|
+
"카드": "card",
|
|
135
|
+
"account_number": "account",
|
|
136
|
+
"계좌번호": "account",
|
|
137
|
+
"계좌": "account",
|
|
138
|
+
"주소": "address",
|
|
139
|
+
"이름": "name",
|
|
140
|
+
"성명": "name",
|
|
141
|
+
"수신자": "name",
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def detect_pii(text: str, policy: PIIPolicy = DEFAULT_POLICY) -> list[PIISpan]:
|
|
146
|
+
"""Detect PII spans in ``text`` according to ``policy``."""
|
|
147
|
+
|
|
148
|
+
spans: list[PIISpan] = []
|
|
149
|
+
if not text:
|
|
150
|
+
return spans
|
|
151
|
+
|
|
152
|
+
if policy.machine_enabled:
|
|
153
|
+
for match in _RRN_RE.finditer(text):
|
|
154
|
+
spans.append(_span("rrn", match.start(), match.end(), match.group(0), "high"))
|
|
155
|
+
for match in _PHONE_RE.finditer(text):
|
|
156
|
+
spans.append(_span("phone", match.start(), match.end(), match.group(0), "high"))
|
|
157
|
+
for match in _EMAIL_RE.finditer(text):
|
|
158
|
+
spans.append(_span("email", match.start(), match.end(), match.group(0), "high"))
|
|
159
|
+
for match in _CARD_RE.finditer(text):
|
|
160
|
+
value = match.group(0)
|
|
161
|
+
if _luhn_valid(_digits(value)):
|
|
162
|
+
spans.append(_span("card", match.start(), match.end(), value, "high"))
|
|
163
|
+
|
|
164
|
+
if policy.contextual_enabled:
|
|
165
|
+
for match in _ACCOUNT_RE.finditer(text):
|
|
166
|
+
value = match.group("value")
|
|
167
|
+
digits = _digits(value)
|
|
168
|
+
if 10 <= len(digits) <= 14:
|
|
169
|
+
spans.append(_span("account", match.start("value"), match.end("value"), value, "low"))
|
|
170
|
+
for match in _ADDRESS_RE.finditer(text):
|
|
171
|
+
value = match.group("value").rstrip()
|
|
172
|
+
start = match.start("value")
|
|
173
|
+
spans.append(_span("address", start, start + len(value), value, "low"))
|
|
174
|
+
for match in _NAME_RE.finditer(text):
|
|
175
|
+
value = match.group("value")
|
|
176
|
+
spans.append(_span("name", match.start("value"), match.end("value"), value, "low"))
|
|
177
|
+
|
|
178
|
+
return _without_overlaps(spans)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def mask_pii(text: str, policy: PIIPolicy = DEFAULT_POLICY) -> str:
|
|
182
|
+
"""Return ``text`` with detected PII spans masked."""
|
|
183
|
+
|
|
184
|
+
masked = text
|
|
185
|
+
for span in sorted(detect_pii(text, policy), key=lambda item: item["start"], reverse=True):
|
|
186
|
+
start = span["start"]
|
|
187
|
+
end = span["end"]
|
|
188
|
+
masked = masked[:start] + mask_value(span["value"], span["type"], policy) + masked[end:]
|
|
189
|
+
return masked
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def mask_value(value: str, pii_type: str, policy: PIIPolicy = DEFAULT_POLICY) -> str:
|
|
193
|
+
"""Mask a single known PII value."""
|
|
194
|
+
|
|
195
|
+
normalized_type = _normalize_type(pii_type)
|
|
196
|
+
if normalized_type == "rrn":
|
|
197
|
+
match = _RRN_RE.fullmatch(value)
|
|
198
|
+
if match:
|
|
199
|
+
return f"{match.group('front')}-{match.group('gender')}{policy.mask_char * 6}"
|
|
200
|
+
digits = _digits(value)
|
|
201
|
+
if len(digits) == 13 and digits[6] in "1234":
|
|
202
|
+
return f"{digits[:6]}-{digits[6]}{policy.mask_char * 6}"
|
|
203
|
+
if normalized_type == "phone":
|
|
204
|
+
match = _PHONE_RE.fullmatch(value)
|
|
205
|
+
if match:
|
|
206
|
+
return f"{match.group('prefix')}-{policy.mask_char * 4}-{policy.mask_char * 4}"
|
|
207
|
+
if normalized_type == "email":
|
|
208
|
+
match = _EMAIL_RE.fullmatch(value)
|
|
209
|
+
if match:
|
|
210
|
+
return f"{match.group('local')[:1]}{policy.mask_char * 4}@{match.group('domain')}"
|
|
211
|
+
if normalized_type == "card":
|
|
212
|
+
digits = _digits(value)
|
|
213
|
+
if len(digits) == 16:
|
|
214
|
+
return f"{digits[:4]}-{policy.mask_char * 4}-{policy.mask_char * 4}-{digits[-4:]}"
|
|
215
|
+
if normalized_type == "account":
|
|
216
|
+
digits = _digits(value)
|
|
217
|
+
suffix_len = policy.suffix_len if policy.suffix_len > 0 else 4
|
|
218
|
+
if len(digits) > suffix_len:
|
|
219
|
+
return f"{policy.mask_char * 3}-{policy.mask_char * 4}-{digits[-suffix_len:]}"
|
|
220
|
+
if normalized_type == "name":
|
|
221
|
+
return _mask_generic(value, policy, suffix_len=0)
|
|
222
|
+
if normalized_type == "address":
|
|
223
|
+
return _mask_generic(value, policy, prefix_len=max(policy.prefix_len, 2), suffix_len=0)
|
|
224
|
+
return _mask_generic(value, policy)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def minimize_fields(
|
|
228
|
+
record: Mapping[str, Any],
|
|
229
|
+
allowed: Iterable[str],
|
|
230
|
+
*,
|
|
231
|
+
drop_empty: bool = False,
|
|
232
|
+
) -> dict[str, Any]:
|
|
233
|
+
"""Return a minimized copy containing only allowed fields.
|
|
234
|
+
|
|
235
|
+
Output order follows the order of ``allowed``. Missing fields are skipped.
|
|
236
|
+
When ``drop_empty`` is true, ``None`` and empty sized values are omitted
|
|
237
|
+
while falsey scalars such as ``0`` and ``False`` are retained.
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
minimized: dict[str, Any] = {}
|
|
241
|
+
for key in allowed:
|
|
242
|
+
if key not in record:
|
|
243
|
+
continue
|
|
244
|
+
value = record[key]
|
|
245
|
+
if drop_empty and _is_empty_value(value):
|
|
246
|
+
continue
|
|
247
|
+
minimized[key] = value
|
|
248
|
+
return minimized
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class Pseudonymizer:
|
|
252
|
+
"""Deterministic in-memory reversible pseudonym token map.
|
|
253
|
+
|
|
254
|
+
``mapping()`` exposes the value-to-token map. Persisting that map outside
|
|
255
|
+
this instance makes the pseudonymization reversible; v1 keeps it in memory
|
|
256
|
+
only.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
def __init__(self) -> None:
|
|
260
|
+
self._tokens: dict[str, str] = {}
|
|
261
|
+
self._counts: dict[str, int] = {}
|
|
262
|
+
|
|
263
|
+
def token(self, value: str, *, kind: str = "이름") -> str:
|
|
264
|
+
"""Return a stable token for ``value`` within this instance."""
|
|
265
|
+
|
|
266
|
+
if value in self._tokens:
|
|
267
|
+
return self._tokens[value]
|
|
268
|
+
count = self._counts.get(kind, 0) + 1
|
|
269
|
+
self._counts[kind] = count
|
|
270
|
+
token = f"{kind}_{count:03d}"
|
|
271
|
+
self._tokens[value] = token
|
|
272
|
+
return token
|
|
273
|
+
|
|
274
|
+
def mapping(self) -> dict[str, str]:
|
|
275
|
+
"""Return a copy of the in-memory value-to-token map."""
|
|
276
|
+
|
|
277
|
+
return dict(self._tokens)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def deidentify(value: str, *, salt: str, length: int = 12) -> str:
|
|
281
|
+
"""Return an irreversible deterministic salted hash token."""
|
|
282
|
+
|
|
283
|
+
digest = hashlib.sha256((salt + "\x00" + value).encode()).hexdigest()
|
|
284
|
+
return "di_" + digest[:length]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class PiiLogFilter(logging.Filter):
|
|
288
|
+
"""Logging filter that masks PII in formatted log messages."""
|
|
289
|
+
|
|
290
|
+
def __init__(self, name: str = "", policy: PIIPolicy = DEFAULT_POLICY) -> None:
|
|
291
|
+
super().__init__(name)
|
|
292
|
+
self.policy = policy
|
|
293
|
+
|
|
294
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
295
|
+
record.msg = _scrub_log_value(record.msg, self.policy)
|
|
296
|
+
record.args = _scrub_log_value(record.args, self.policy)
|
|
297
|
+
try:
|
|
298
|
+
message = record.getMessage()
|
|
299
|
+
except Exception:
|
|
300
|
+
message = str(record.msg)
|
|
301
|
+
masked_message = mask_pii(message, self.policy)
|
|
302
|
+
record.msg = masked_message
|
|
303
|
+
record.args = ()
|
|
304
|
+
return True
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def scrub_exception_message(msg: str, policy: PIIPolicy = DEFAULT_POLICY) -> str:
|
|
308
|
+
"""Mask PII in an exception message before exposing or logging it."""
|
|
309
|
+
|
|
310
|
+
return mask_pii(msg, policy)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _span(kind: str, start: int, end: int, value: str, confidence: Confidence) -> PIISpan:
|
|
314
|
+
return {"type": kind, "start": start, "end": end, "value": value, "confidence": confidence}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _is_empty_value(value: Any) -> bool:
|
|
318
|
+
if value is None:
|
|
319
|
+
return True
|
|
320
|
+
return isinstance(value, Sized) and len(value) == 0
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _scrub_log_value(value: Any, policy: PIIPolicy) -> Any:
|
|
324
|
+
if isinstance(value, str):
|
|
325
|
+
return mask_pii(value, policy)
|
|
326
|
+
if isinstance(value, tuple):
|
|
327
|
+
return tuple(_scrub_log_value(item, policy) for item in value)
|
|
328
|
+
if isinstance(value, Mapping):
|
|
329
|
+
return {key: _scrub_log_value(item, policy) for key, item in value.items()}
|
|
330
|
+
return value
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _normalize_type(pii_type: str) -> str:
|
|
334
|
+
lowered = pii_type.strip().lower()
|
|
335
|
+
return _TYPE_ALIASES.get(lowered, lowered)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _digits(value: str) -> str:
|
|
339
|
+
return "".join(ch for ch in value if ch.isdigit())
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _luhn_valid(digits: str) -> bool:
|
|
343
|
+
if len(digits) != 16 or not digits.isdigit():
|
|
344
|
+
return False
|
|
345
|
+
total = 0
|
|
346
|
+
double = False
|
|
347
|
+
for char in reversed(digits):
|
|
348
|
+
value = int(char)
|
|
349
|
+
if double:
|
|
350
|
+
value *= 2
|
|
351
|
+
if value > 9:
|
|
352
|
+
value -= 9
|
|
353
|
+
total += value
|
|
354
|
+
double = not double
|
|
355
|
+
return total % 10 == 0
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _without_overlaps(spans: list[PIISpan]) -> list[PIISpan]:
|
|
359
|
+
selected: list[PIISpan] = []
|
|
360
|
+
for span in sorted(spans, key=lambda item: (item["start"], -(item["end"] - item["start"]))):
|
|
361
|
+
if any(_overlaps(span, existing) for existing in selected):
|
|
362
|
+
continue
|
|
363
|
+
selected.append(span)
|
|
364
|
+
return sorted(selected, key=lambda item: (item["start"], item["end"]))
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _overlaps(left: PIISpan, right: PIISpan) -> bool:
|
|
368
|
+
return left["start"] < right["end"] and right["start"] < left["end"]
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _mask_generic(
|
|
372
|
+
value: str,
|
|
373
|
+
policy: PIIPolicy,
|
|
374
|
+
*,
|
|
375
|
+
prefix_len: int | None = None,
|
|
376
|
+
suffix_len: int | None = None,
|
|
377
|
+
) -> str:
|
|
378
|
+
if not value:
|
|
379
|
+
return value
|
|
380
|
+
requested_prefix = policy.prefix_len if prefix_len is None else prefix_len
|
|
381
|
+
requested_suffix = policy.suffix_len if suffix_len is None else suffix_len
|
|
382
|
+
if len(value) <= requested_prefix + requested_suffix:
|
|
383
|
+
visible_prefix = min(requested_prefix, max(len(value) - 1, 0))
|
|
384
|
+
return f"{value[:visible_prefix]}{policy.mask_char * (len(value) - visible_prefix)}"
|
|
385
|
+
prefix = min(len(value), requested_prefix)
|
|
386
|
+
suffix = min(len(value) - prefix, requested_suffix)
|
|
387
|
+
mask_count = len(value) - prefix - suffix
|
|
388
|
+
suffix_text = value[-suffix:] if suffix else ""
|
|
389
|
+
return f"{value[:prefix]}{policy.mask_char * mask_count}{suffix_text}"
|
|
@@ -103,19 +103,20 @@ hwpx/tools/__init__.py,sha256=SntwT_dutXSMyqyXbUK-vmyns91EBoEhqXDfS600C0Y,4120
|
|
|
103
103
|
hwpx/tools/advanced_generators.py,sha256=ucUNnT2Pkto2jiyCU5_c1abbiskO65WOimgH0l-3Tv0,5164
|
|
104
104
|
hwpx/tools/archive_cli.py,sha256=gkQHuCPUqNGy2S2rKis9NOI1-xZCfwDakF9o8kikPuQ,12775
|
|
105
105
|
hwpx/tools/doc_diff.py,sha256=Yr_MxP9Z1UDmrB7KdL2Bts_nouQ23_WLTSD0yn6Oyu0,12079
|
|
106
|
-
hwpx/tools/exporter.py,sha256=
|
|
106
|
+
hwpx/tools/exporter.py,sha256=QhGgZXfJ5ccTQpfvnKov38io8aQSEUByQ4tPjhRvNso,7373
|
|
107
107
|
hwpx/tools/generic_inventory.py,sha256=pHVP8-htX_vO02ARdQR37XFxm7fUPK68VtMeeOJ1NZY,4835
|
|
108
108
|
hwpx/tools/id_integrity.py,sha256=TBI2WOwbaMbwTGyMv0hEO-uZqyewZtdvawqqA42Tums,14245
|
|
109
109
|
hwpx/tools/idempotence.py,sha256=kRZBWsPJFVprYRJkkxn3DHjrT6qX2nK7KvhmdgJ4JpA,5017
|
|
110
110
|
hwpx/tools/ir_equality.py,sha256=NIjq33-4q7iTo_asv7WUvh_-EfB1JZMTwbLfYIw2c5w,4765
|
|
111
111
|
hwpx/tools/layout_preview.py,sha256=XdKxNL34zVBvFDQv5LFErz1ESuVhkW8jHdyQ1EGbSBU,19857
|
|
112
|
-
hwpx/tools/mail_merge.py,sha256=
|
|
112
|
+
hwpx/tools/mail_merge.py,sha256=l3ukaW9noQga0jq_3xWZAdtniBlHHLrSGIGYer0xFW8,18447
|
|
113
113
|
hwpx/tools/markdown_export.py,sha256=FejutCpQHbycO185uljcSwfZuwXMTbGEgXtf5e-a4_k,19139
|
|
114
114
|
hwpx/tools/object_finder.py,sha256=7i6XI1-r7-ar_IzSZQ82hfOcxVzJFK2XjMDB8oxcmMA,13478
|
|
115
115
|
hwpx/tools/official_lint.py,sha256=9iszI4CAAEMGdPCOhmQ6XonO1jc1XE2BJXMHgv9hIvY,18600
|
|
116
116
|
hwpx/tools/package_reconcile.py,sha256=y1Hl7hbPh4YaV59LTdDLzQwgn4g1qEnFmSjmajnrEbA,2416
|
|
117
117
|
hwpx/tools/package_validator.py,sha256=AA5wy6YgwlU6BTq1p2qCbCVCM8lmIBLhPANKCfaPb-s,29369
|
|
118
118
|
hwpx/tools/page_guard.py,sha256=nDAVPcvrnuyDxVTA_j22wiYD7CXAD6XlzsMzaz3h_q8,9701
|
|
119
|
+
hwpx/tools/pii.py,sha256=N3c36eqblaVQ7o6jiT1BV0WrJK_G3gKhQh6MQGAbvCs,12617
|
|
119
120
|
hwpx/tools/recover.py,sha256=EOVAzMFAqR9YAT3sinZKCdjSkKygo4dKrs6T6SbGA7o,4963
|
|
120
121
|
hwpx/tools/redline.py,sha256=p6aMVDBOrkqywlrlxqhB-5ZjCEtgrCNa8cYeRMJlprk,6478
|
|
121
122
|
hwpx/tools/repair.py,sha256=wYO4Zd8ZMwkbYy2_EPz0eyqvTCrzcAyvbEsVE1P87Zk,11197
|
|
@@ -146,10 +147,10 @@ hwpx/visual/diff.py,sha256=0X5T9IgwRZU3td-7vnPrlowovtGud7P_ymq0KVehlKk,5677
|
|
|
146
147
|
hwpx/visual/masks.py,sha256=oXhgynAb4uKjJtZ2BGHHdAjyvWGqSFlZFQ-iJxzHiuo,1832
|
|
147
148
|
hwpx/visual/oracle.py,sha256=QXAyc0xVIjLPWUHM2rBjsustQTmwfoW2Xkb9iX9ai2E,21786
|
|
148
149
|
hwpx/visual/report.py,sha256=2RhXN1KBYOZTim9FNpeUhaaDHR7oFxI6Z2DLUkDIiwE,1717
|
|
149
|
-
python_hwpx-2.
|
|
150
|
-
python_hwpx-2.
|
|
151
|
-
python_hwpx-2.
|
|
152
|
-
python_hwpx-2.
|
|
153
|
-
python_hwpx-2.
|
|
154
|
-
python_hwpx-2.
|
|
155
|
-
python_hwpx-2.
|
|
150
|
+
python_hwpx-2.18.0.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
|
|
151
|
+
python_hwpx-2.18.0.dist-info/licenses/NOTICE,sha256=k48h6EaGQE8Y1c0dS9sIOOcz4YqkbcImWClF7pBOgsg,2473
|
|
152
|
+
python_hwpx-2.18.0.dist-info/METADATA,sha256=uHs8_pBm80hLCUNrfs20a6szLITYaWgK-s8UloSmA68,19982
|
|
153
|
+
python_hwpx-2.18.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
154
|
+
python_hwpx-2.18.0.dist-info/entry_points.txt,sha256=4U6WXYWHxEiWp2VRHo97fvOYNh7ebu6roonk7chxKcY,453
|
|
155
|
+
python_hwpx-2.18.0.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
|
|
156
|
+
python_hwpx-2.18.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|