From 9fa36e8bec73216d16c999de1ddfb8bc2cf41d75 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 07:46:28 +0000 Subject: [PATCH 1/2] Fix leaks and round-trip failures found by the third fuzzing round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fuzzer for tables and CSV, numbered lists, Latin look-alike letters, double first names and documents with dozens of people repeated in several cases, plus large-document runs (up to 8000 lines), found these. Each has a test in tests/test_fuzz_findings3.py. Left unmasked: - CSV cells: a ';' glued to the next word separates cells like '|'; a glued comma reads as ', '. - Latin look-alike letters inside names are read as Cyrillic when names and patronymics are analysed, so the mask is never the real name and the role of each word is found. - Ranks with 'запасу'/'резерву' and the dative in '-ові' (a new form, rank_data.RANK_DATIVE_OVI, with masks that have it too). - A multi-word rank right after another rank (lines joined for parsing) is the person's rank, not a surname. Unmasking: - Occurrences are numbered regardless of the mask's letter case; new mappings carry "instance_case": "insensitive", older ones are read as before. - The case of double names and surnames is restored part by part from the text. - A rank's mask skips every rank that stays open in the document, not only the neighbours two steps away; when none is free, the rank stays. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XT6iUWaQgahXDB9TWX9Bq7 Generated-With: Claude Code 2.1.42 --- CHANGELOG.md | 44 ++++++ datamasking/extras/tools.py | 8 +- datamasking/masking/constants.py | 4 + datamasking/masking/context.py | 30 +++- datamasking/masking/declension.py | 16 +- datamasking/masking/engine.py | 25 +++- datamasking/masking/helpers.py | 31 +++- datamasking/masking/language.py | 4 +- datamasking/masking/mask_military.py | 46 +++++- datamasking/masking/mask_personal.py | 8 +- datamasking/rank_data.py | 26 ++++ datamasking/unmasking/engine.py | 27 +++- datamasking/unmasking/helpers.py | 19 ++- tests/test_fuzz_findings3.py | 214 +++++++++++++++++++++++++++ tests/test_rank_data.py | 3 +- 15 files changed, 468 insertions(+), 37 deletions(-) create mode 100644 tests/test_fuzz_findings3.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 95778cb..d0aba0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,50 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Fixed — data left unmasked +- Tables exported from Excel as CSV (`Петренко Іван Петрович;сержант;1234567890`, + `Петренко;Іван;Петрович;сержант`): a `;` with no space after it now + separates cells like `|` does, so the name, the rank and the patronymic + in the last cell are masked. Before, the patronymic glued to the next cell + stayed open. A `;` followed by a space still ends a name, as in prose. A + comma glued to the next word (`Петрович,сержант`) is read as `, `. +- A Latin letter that looks like a Cyrillic one inside a name (`Ярoслав` + with a Latin `o`, typical of text pasted from elsewhere) made the name + unrecognised. Its mask could then be the real name (`Ярослав`), and a + surname in an oblique case next to it (`Литвином`) stayed open. Names and + patronymics are now analysed with such letters read as Cyrillic; the + mapping keeps the original spelling. +- Ranks followed by `запасу` or `резерву` (`майор запасу Петренко …`) and + ranks in the dative ending in `-ові` (`майорові`, `солдатові`, + `старшому лейтенантові`) were not recognised and stayed open next to a + masked name. The `-ові` form gets a mask that has the same form. +- After a rank at the end of a line, a rank at the start of the next line + (`… капітаном` / `СТАРШИЙ ЛЕЙТЕНАНТ ПИЛИП'ЮК …`) had its first word masked + as a surname, also inside the rank's mask (`СТЕМИЙ СТЕМИЙ ПРАПОРЩИК`). A + rank of several words right after another rank is now the person's rank. + +### Fixed — unmasking did not restore the original +- When two values got masks that differ only in letter case (a patronymic + first seen in a line in capitals, `ГОРДІЇВНИ`, and another one, + `Гордіївни`), one occurrence was not restored: masking numbered the + occurrences by the mask's case at its first use, while the text shows the + mask in the case of each occurrence. Occurrences are now numbered + regardless of case, and new mappings say so (`"instance_case": + "insensitive"`); mappings from earlier versions are unmasked as before. +- A double surname that also appears in a line in capitals came back in + capitals in an ordinary line (`ДОРОШЕНКУ-ҐУДЗЮ` instead of + `Дорошенку-Ґудзю`); the case of each part is now taken from the text. + A double first name came back with a lower-case second part + (`Анна-марія`). +- In a long order that mentions many ranks without names, a rank's mask could + equal one of those open ranks: only the neighbours two steps up or down + were tried. Unmasking then replaced the open rank (`доповіла капітану роти` + came back as `майору`). The nearest free rank of the hierarchy is now taken. + When every rank of the hierarchy is open in the document, the rank is left + as it is rather than masked with a rank that unmasking would confuse. + ## [3.1.21] - 2026-11 ### Fixed — names left unmasked diff --git a/datamasking/extras/tools.py b/datamasking/extras/tools.py index efb2b8a..d4593c3 100644 --- a/datamasking/extras/tools.py +++ b/datamasking/extras/tools.py @@ -277,9 +277,9 @@ def get_next_instance(masked_value: str, instance_counters: Dict[str, int]) -> i Returns: The incremented instance number (starting from 1). """ - instance_counters.setdefault(masked_value, 0) - instance_counters[masked_value] += 1 - return instance_counters[masked_value] + # Без урахування регістру маски — як у рушії (masking/helpers.py) + from datamasking.masking.helpers import get_next_instance as _engine_next + return _engine_next(masked_value, instance_counters) def add_to_mapping( @@ -327,6 +327,8 @@ def add_to_mapping( else: masked = masking_dict["mappings"][entity_type][original]["masked_as"] + from datamasking.masking.helpers import INSTANCE_CASE_INSENSITIVE, INSTANCE_CASE_KEY + masking_dict[INSTANCE_CASE_KEY] = INSTANCE_CASE_INSENSITIVE instance_num = get_next_instance(masked, instance_counters) masking_dict["mappings"][entity_type][original]["instances"].append( instance_num diff --git a/datamasking/masking/constants.py b/datamasking/masking/constants.py index e0bdb09..eb9aca8 100644 --- a/datamasking/masking/constants.py +++ b/datamasking/masking/constants.py @@ -11,6 +11,7 @@ from faker import Faker from datamasking.rank_data import ( + RANK_DATIVE_OVI, RANK_DECLENSIONS, RANK_FEMININE_MAP, RANK_DECLENSIONS_FEMALE, @@ -165,6 +166,9 @@ def set_faker_locale(locale: str) -> None: # жіноча форма маскується чоловічою і unmask повертає «солдата» замість # «солдатки» (v3.1.14). Задається рушієм на час обробки документа. FEMININE_RANK_BASES: frozenset = frozenset() +# Звання, що в документі стоять у давальному на «-ові» (див. rank_data. +# RANK_DATIVE_OVI): маска має теж мати цю форму. Задається рушієм +OVI_RANK_BASES: frozenset = frozenset() # Базові звання, які в поточному документі записані з нерозривним пробілом, # табом чи кількома пробілами між словами: маска має ту саму кількість слів, # щоб роздільники перенеслись у неї й назад (після 3.1.20). Задається рушієм. diff --git a/datamasking/masking/context.py b/datamasking/masking/context.py index de000ac..77df9c1 100644 --- a/datamasking/masking/context.py +++ b/datamasking/masking/context.py @@ -11,7 +11,7 @@ from typing import Dict, List, Optional, Set, Tuple from datamasking.masking import constants as _cfg -from datamasking.masking.declension import VOCATIVE, analyze_name, analyze_patronymic +from datamasking.masking.declension import VOCATIVE, analyze_name, analyze_patronymic, fold_homoglyphs from datamasking.masking.helpers import normalize_string, normalize_identifier, is_pib_anchor, strip_word, ends_clause from datamasking.masking.language import looks_like_name, detect_gender_by_patronymic, is_likely_surname_by_case @@ -19,7 +19,7 @@ # NBSP) і «|» таблиць. До 3.1.7 ПІБ із табуляцією або подвійним пробілом # між словами не маскувався взагалі: parse_hybrid_line стискав пробіли, а # рушій шукав зібраний ПІБ дослівно в рядку (і не знаходив). -WORD_SEPARATOR_RE = re.compile(r"[\s|]+") +WORD_SEPARATOR_RE = re.compile(r"[\s|;]+") # Канцелярські слова, після яких кандидат на ПІБ — не ПІБ («Наказ # Міністерства…»). Збіг — лише цілим словом: до 3.1.7 порівнювався підрядок, @@ -178,6 +178,13 @@ def clean_line_before_parsing(line: str) -> str: # «|» таблиць — як пробіл (рушій потім знаходить слова ПІБ у рядку з # оригінальними роздільниками, див. engine.locate_words) line = line.replace('|', ' ') + # CSV з Excel («Петренко;Іван;Петрович;сержант»): «;» впритул до + # наступного слова — роздільник клітинок, як «|»; «; » у тексті й далі + # завершує ПІБ. Кома впритул до слова — як «, ». До 3.1.22 клітинка + # «Петрович;сержант;1234567890» була одним словом, і по батькові + # лишалось відкритим + line = re.sub(r';(?=\S)', ' ', line) + line = re.sub(r',(?=[^\W\d_])', ', ', line) # Видаляємо нумерацію пунктів на початку рядка: "20.1.2.1.", "1.", "1.2.", "3.2." тощо line = re.sub(r'^\s*(?:\d+\.)+\s*', '', line) line = re.sub(r'\d{1,2}[.!]\d{1,2}\.\d{4}', '', line) @@ -196,7 +203,8 @@ def extract_identifier_from_line(line: str) -> Optional[str]: def extract_base_rank(full_rank_text: str) -> Tuple[str, str]: if not full_rank_text: return full_rank_text, "" service_type_phrases = ['медичної служби', 'юстиції'] - status_phrases = ['у відставці', 'в запасі', 'у запасі', 'на пенсії', 'в резерві', 'у резерві'] + status_phrases = ['у відставці', 'в запасі', 'у запасі', 'на пенсії', 'в резерві', 'у резерві', + 'запасу', 'резерву'] full_rank_lower = full_rank_text.lower() base_rank = full_rank_text additional_parts = [] @@ -363,6 +371,9 @@ def parse_hybrid_line(line: str) -> Tuple[Optional[str], Optional[str], Optional if words_after and words_after[0] in ['у', 'в', 'на']: if len(words_after) > 1 and words_after[1] in ['відставці', 'запасі', 'пенсії', 'резерві']: additional_words += 2 + elif words_after and words_after[0] in ['запасу', 'резерву']: + # «майор запасу Петренко …» (до 3.1.22 звання лишалось відкритим) + additional_words += 1 rank_matches.append((rank_index, rank_form, first_tok, last_tok - first_tok + 1 + additional_words)) @@ -370,6 +381,15 @@ def parse_hybrid_line(line: str) -> Tuple[Optional[str], Optional[str], Optional rank_matches.sort(key=lambda x: x[0]) rank_index, found_rank, rank_position, rank_word_count = rank_matches[0] pib_start_index = rank_position + rank_word_count + # Одразу за званням — ще одне звання з кількох слів («…капітаном + # СТАРШИЙ ЛЕЙТЕНАНТ ПИЛИП'ЮК…», рядки, склеєні на час розбору): ПІБ + # іде після нього, і звання особи — воно. До 3.1.22 «СТАРШИЙ» + # маскувалось як прізвище. Звання з одного слова («капітан Майор Іван + # Іванович») може бути прізвищем, тож його не пропускаємо + multiword = {m[2]: m for m in rank_matches if len(m[1].split()) > 1} + while pib_start_index in multiword and multiword[pib_start_index][2] != rank_position: + rank_index, found_rank, rank_position, rank_word_count = multiword[pib_start_index] + pib_start_index = rank_position + rank_word_count rank_words = [strip_word(w) for w in parts[rank_position:rank_position + rank_word_count]] found_rank_original_case = ' '.join(rank_words) if rank_words else found_rank @@ -513,7 +533,7 @@ def roles_plausible(pib_words: List[str], after_rank: bool = False) -> bool: if len(pib_words) < 2: return True surname, name, patronymic, _order = assign_pib_roles(pib_words) - name_low = _strip_word(name).lower() + name_low = fold_homoglyphs(_strip_word(name)) if name_low.endswith(_NOT_A_FIRST_NAME_ENDINGS): return False if name_low.endswith(_PLURAL_LIKE_ENDINGS) and not analyze_name(name_low).known: @@ -522,7 +542,7 @@ def roles_plausible(pib_words: List[str], after_rank: bool = False) -> bool: and not analyze_name(name_low).known): return False if surname: - surname_low = _strip_word(surname).lower() + surname_low = fold_homoglyphs(_strip_word(surname)) if len(surname_low) >= 7 and surname_low.endswith(('ом', 'ем')) \ and not name_low.endswith(_INSTRUMENTAL_NAME_ENDINGS): return False diff --git a/datamasking/masking/declension.py b/datamasking/masking/declension.py index 14c0f71..7612632 100644 --- a/datamasking/masking/declension.py +++ b/datamasking/masking/declension.py @@ -82,8 +82,22 @@ _FEMALE_NAMES: Optional[Set[str]] = None +# Латинські літери, що виглядають як кириличні (a c e i o p x y k), — у +# кирилицю, якщо в слові вже є кирилиця: «Ярoслав» з латинською «o» (копія +# з іншого документа) інакше не впізнавалось як ім'я, а маскою могло стати +# «Ярослав» — справжнє ім'я (до 3.1.22). Слово лише з латиниці не змінюється +_HOMOGLYPHS = str.maketrans("aceiopxyk", "асеіорхук") +_CYRILLIC_RE = re.compile(r"[а-яіїєґ]") + + +def fold_homoglyphs(word: str) -> str: + """Нижній регістр слова з латинськими двійниками, заміненими кирилицею.""" + low = word.lower() + return low.translate(_HOMOGLYPHS) if _CYRILLIC_RE.search(low) else low + + def _normalize(word: str) -> str: - return word.replace("ʼ", "'").replace("’", "'").lower() + return fold_homoglyphs(word.replace("ʼ", "'").replace("’", "'")) def _faker_names() -> Tuple[Set[str], Set[str]]: diff --git a/datamasking/masking/engine.py b/datamasking/masking/engine.py index 5cca764..385cdad 100644 --- a/datamasking/masking/engine.py +++ b/datamasking/masking/engine.py @@ -432,7 +432,7 @@ def _rank_tail_words() -> frozenset: global _RANK_TAIL_WORDS if _RANK_TAIL_WORDS is None: words = {w for form in _cfg.ALL_RANK_FORMS for w in form.split()} - words |= {"служби", "юстиції", "відставці", "запасі", "запасу", "резерві", "пенсії"} + words |= {"служби", "юстиції", "відставці", "запасі", "запасу", "резерві", "резерву", "пенсії"} _RANK_TAIL_WORDS = frozenset(words) return _RANK_TAIL_WORDS @@ -625,12 +625,12 @@ def _mask_text_context_aware_impl(text: str, masking_dict: Dict, instance_counte def locate_words(line: str, phrase: str) -> Optional[Tuple[int, int, List[str]]]: """Знаходить слова *phrase* (розділені пробілом) у *line*, де між ними може стояти будь-який роздільник: кілька пробілів, таб, NBSP, «|» - таблиць. Повертає (start, end, роздільники між словами) або None. + таблиць, «;» клітинок CSV. Повертає (start, end, роздільники між словами) або None. До 3.1.7 ПІБ шукався дослівно, і «Коваль\tТетяна\tСергіївна» не маскувався.""" words = phrase.split() if not words: return None - pattern = r"(? Optional[Tuple[i words = phrase.split() if not words: return None - pattern = r"(? Optional[Tuple[i def _words_pattern(phrase: str) -> str: - return (r"(? Tuple[Optional[Tuple[int, int, List[str]]], @@ -792,6 +792,8 @@ def _mask_text_core(text: str, masking_dict: Dict, instance_counters: Dict, # формою (див. mask_rank, v3.1.14) — і в пробному, і в основному прогоні saved_feminine = _cfg.FEMININE_RANK_BASES _cfg.FEMININE_RANK_BASES = _feminine_rank_bases(text) + saved_ovi = _cfg.OVI_RANK_BASES + _cfg.OVI_RANK_BASES = _ovi_rank_bases(text) saved_spaced = _cfg.SPACED_RANK_BASES _cfg.SPACED_RANK_BASES = _spaced_rank_bases(text) saved = _cfg.AVOID_RANK_MASKS @@ -804,6 +806,7 @@ def _mask_text_core(text: str, masking_dict: Dict, instance_counters: Dict, finally: _cfg.AVOID_RANK_MASKS = saved _cfg.FEMININE_RANK_BASES = saved_feminine + _cfg.OVI_RANK_BASES = saved_ovi _cfg.SPACED_RANK_BASES = saved_spaced _surname.set_residue(None) return ("\ufeff" + text) if bom else text @@ -820,6 +823,14 @@ def _feminine_rank_bases(text: str) -> frozenset: return frozenset(bases) +def _ovi_rank_bases(text: str) -> frozenset: + """Базові звання, що в тексті стоять у давальному на «-ові» («майорові»).""" + lowered = re.sub(r"\s+", " ", text.lower()) + return frozenset( + base.lower() for form, (base, case, _gender) in _cfg.RANK_TO_NOMINATIVE.items() + if case == "dative_ovi" and re.search(r"(? Pattern: _RANK_SPAN_CACHE[key] = re.compile( r"(? None: ) +# Позначка mapping-у (з 3.1.22): номери входжень однієї маски рахуються без +# урахування її регістру. Маска виводиться в регістрі кожного входження +# («ГОРДІЇВНИ» в рядку капсом, «Гордіївни» далі), і лічильник за точним +# регістром першої появи розходився з тим, що бачив unmask: коли два +# оригінали мали маски, що різнились лише регістром, одне входження не +# відновлювалось. Mapping без позначки (старіші версії) unmask читає як раніше +INSTANCE_CASE_KEY = "instance_case" +INSTANCE_CASE_INSENSITIVE = "insensitive" + + def get_next_instance(masked_value: str, instance_counters: Dict[str, int]) -> int: """ - Повертає наступний номер екземпляра для даного замаскованого значення. + Повертає наступний номер екземпляра для даного замаскованого значення + (без урахування регістру — див. INSTANCE_CASE_KEY). Instance tracking: відстежуємо випадки коли різні оригінали маскуються в одне значення (колізії) """ - instance_counters.setdefault(masked_value, 0) - instance_counters[masked_value] += 1 - return instance_counters[masked_value] + key = masked_value.lower() + instance_counters.setdefault(key, 0) + instance_counters[key] += 1 + return instance_counters[key] + + +def record_instance(masking_dict: Dict, instance_counters: Dict[str, int], category: str, + original: str, masked: str) -> None: + """Наступний номер входження *masked* — у instances запису *original*.""" + masking_dict[INSTANCE_CASE_KEY] = INSTANCE_CASE_INSENSITIVE + instance_num = get_next_instance(masked, instance_counters) + masking_dict["mappings"][category][original]["instances"].append(instance_num) def add_to_mapping(masking_dict: Dict, instance_counters: Dict, category: str, original: str, masked: str) -> str: """ @@ -73,8 +93,7 @@ def add_to_mapping(masking_dict: Dict, instance_counters: Dict, category: str, o masked = masking_dict["mappings"][category][original]["masked_as"] # Instance tracking: зберігаємо кожне входження - instance_num = get_next_instance(masked, instance_counters) - masking_dict["mappings"][category][original]["instances"].append(instance_num) + record_instance(masking_dict, instance_counters, category, original, masked) return masked def _apply_original_case(original: str, masked: str) -> str: diff --git a/datamasking/masking/language.py b/datamasking/masking/language.py index c1e70e5..5a25ffe 100644 --- a/datamasking/masking/language.py +++ b/datamasking/masking/language.py @@ -13,7 +13,7 @@ from datamasking.masking import constants as _cfg from datamasking.masking.declension import ( - VOCATIVE, analyze_name, analyze_patronymic, decline_name, + VOCATIVE, analyze_name, analyze_patronymic, decline_name, fold_homoglyphs, ) from datamasking.masking.helpers import get_deterministic_seed, strip_word @@ -129,7 +129,7 @@ def same_name_forms(candidate: str, original: str) -> bool: закінчення (≤ 1 літери від коротшого слова). До 3.1.6 порівнювалась лише точна форма, і «Олега» маскувалось як «Олег» — справжнє ім'я (7 із 15 поширених імен у родовому відмінку).""" - a, b = (candidate or "").lower(), (original or "").lower() + a, b = fold_homoglyphs(candidate or ""), fold_homoglyphs(original or "") if not a or not b: return False if a == b: diff --git a/datamasking/masking/mask_military.py b/datamasking/masking/mask_military.py index 8dafb84..6b58fc7 100644 --- a/datamasking/masking/mask_military.py +++ b/datamasking/masking/mask_military.py @@ -344,6 +344,10 @@ def get_rank_info(rank_form: str) -> Tuple[Optional[str], Optional[str], Optiona def get_rank_in_case(nominative_rank: str, target_case: str) -> str: if nominative_rank not in _cfg.RANK_DECLENSIONS: return nominative_rank + if target_case == "dative_ovi": + # Звання без форми на «-ові» (рядовий) — звичайний давальний + return (_cfg.RANK_DATIVE_OVI.get(nominative_rank) + or _cfg.RANK_DECLENSIONS[nominative_rank]["dative"]) return _cfg.RANK_DECLENSIONS[nominative_rank].get(target_case, nominative_rank) def mask_rank(original: str, masking_dict: Dict, instance_counters: Dict) -> str: @@ -417,6 +421,24 @@ def mask_rank(original: str, masking_dict: Dict, instance_counters: Dict) -> str if alt.lower() != search_key and alt.lower() not in _cfg.AVOID_RANK_MASKS: masked = alt break + else: + # Усі сусіди за зсувом відкрито стоять у документі (великий наказ, + # де згадано десятки звань) — найближче вільне звання ієрархії. + # До 3.1.22 лишався перший варіант, і unmask підміняв відкрите + # звання («доповіла капітану роти» → «майору») + for distance in range(1, len(hierarchy)): + free = [hierarchy[i] for i in (idx + distance, idx - distance) + if 0 <= i < len(hierarchy) and hierarchy[i].lower() != search_key + and hierarchy[i].lower() not in _cfg.AVOID_RANK_MASKS] + if free: + masked = free[0] + break + else: + # Вільних звань немає зовсім (кожне звання ієрархії десь у + # документі стоїть відкрито): будь-яка маска збіглася б із + # відкритим текстом, і unmask мовчки ставив би чужі звання. + # Звання лишається як є — так воно відновлюється точно + masked = search_key # Звання, що в документі стоїть і в жіночій формі («солдатки»), потребує # маски з жіночою формою: «рядовий» її не має, і «солдатки» ставало @@ -456,6 +478,26 @@ def mask_rank(original: str, masking_dict: Dict, instance_counters: Dict) -> str continue break + # Звання, що в документі стоїть у давальному на «-ові» («майорові»), + # потребує маски з такою формою — інакше «рядовому» розмасковувалось би + # в «майору» (після 3.1.21). Решта вимог — як у блоках вище + if search_key in _cfg.OVI_RANK_BASES and masked.lower() not in _cfg.RANK_DATIVE_OVI: + need_feminine = search_key in _cfg.FEMININE_RANK_BASES + same_words = len(search_key.split()) if search_key in _cfg.SPACED_RANK_BASES else None + for distance in range(1, len(hierarchy)): + for alt_idx in (idx + distance, idx - distance): + if 0 <= alt_idx < len(hierarchy): + alt = hierarchy[alt_idx] + if (alt.lower() != search_key and alt.lower() in _cfg.RANK_DATIVE_OVI + and alt.lower() not in _cfg.AVOID_RANK_MASKS + and (same_words is None or len(alt.split()) == same_words) + and (not need_feminine or alt.lower() in _cfg.RANK_FEMININE_MAP)): + masked = alt + break + else: + continue + break + # Store mapping by base form (nominative) for consistency final_masked = add_to_mapping(masking_dict, instance_counters, "rank", lookup_key, masked) @@ -501,11 +543,11 @@ def mask_rank_preserve_case(original_text: str, masking_dict: Dict, instance_cou else: final_rank = masked_base_female else: if masked_base_male.lower() in _cfg.RANK_DECLENSIONS and detected_case: - final_rank = _cfg.RANK_DECLENSIONS[masked_base_male.lower()].get(detected_case, masked_base_male) + final_rank = get_rank_in_case(masked_base_male.lower(), detected_case) else: final_rank = masked_base_male else: if masked_base_male.lower() in _cfg.RANK_DECLENSIONS and detected_case: - final_rank = _cfg.RANK_DECLENSIONS[masked_base_male.lower()].get(detected_case, masked_base_male) + final_rank = get_rank_in_case(masked_base_male.lower(), detected_case) else: final_rank = masked_base_male if _cfg.PRESERVE_CASE and final_rank: diff --git a/datamasking/masking/mask_personal.py b/datamasking/masking/mask_personal.py index 4d54d1e..37c294a 100644 --- a/datamasking/masking/mask_personal.py +++ b/datamasking/masking/mask_personal.py @@ -13,11 +13,12 @@ from datamasking.masking import constants as _cfg from datamasking.masking.helpers import ( - add_to_mapping, get_deterministic_seed, get_next_instance, + add_to_mapping, get_deterministic_seed, record_instance, _apply_original_case, normalize_identifier, ) from datamasking.masking.declension import ( NOMINATIVE, VOCATIVE, analyze_name, analyze_patronymic, decline_name, decline_patronymic, + fold_homoglyphs, ) from datamasking.masking.language import ( generate_easy_name, same_name_forms, normalize_apostrophe, @@ -161,8 +162,7 @@ def mask_patronymic(patronymic: str, gender: str, masking_dict: Dict, instance_c if patronymic_lower in masking_dict["mappings"]["patronymic"]: masked = masking_dict["mappings"]["patronymic"][patronymic_lower]["masked_as"] masked_with_case = _apply_original_case(patronymic, masked) - instance_num = get_next_instance(masked, instance_counters) - masking_dict["mappings"]["patronymic"][patronymic_lower]["instances"].append(instance_num) + record_instance(masking_dict, instance_counters, "patronymic", patronymic_lower, masked) return masked_with_case form = analyze_patronymic(patronymic_lower) @@ -248,7 +248,7 @@ def mask_name(original: str, masking_dict: Dict, instance_counters: Dict, # відмінка оригіналу (в регістрі оригіналу), тож усі відмінки одного # імені дають одну маску: «Петро/Петра/Петром» → «Павло/Павла/Павлом» # (v3.1.12; для називного відмінка — як у попередніх версіях) - first_letter = original[0].lower() + first_letter = fold_homoglyphs(original[0]) lemma = _apply_original_case(original, form.nominative) seed = get_deterministic_seed(lemma) doc_words = _surname.residue_vocabulary() diff --git a/datamasking/rank_data.py b/datamasking/rank_data.py index ce27d1b..2efa4c8 100644 --- a/datamasking/rank_data.py +++ b/datamasking/rank_data.py @@ -533,6 +533,32 @@ # Зберігаємо базову ЧОЛОВІЧУ форму для уніфікації маскування RANK_TO_NOMINATIVE[case_form.lower()] = (base_male_form, case_name, 'female') +# Давальний на «-ові» («майорові», «старшому лейтенантові», «капітанові +# 1-го рангу») — поширений у наказах («оголосити подяку солдатові …»); до +# 3.1.22 такі звання не розпізнавались і лишались відкритими. Окрема форма +# (відмінок "dative_ovi"), лише для звань, у яких давальний — це іменник +# на приголосний + «у»; прикметникові (рядовий) її не мають + + +def _dative_ovi(nominative: str, dative: str): + nw, dw = nominative.split(), dative.split() + if len(nw) != len(dw): + return None + hits = [i for i, (n, d) in enumerate(zip(nw, dw)) + if d == n + "у" and n[-1:] not in "аеєиіїоуюяйь"] + if len(hits) != 1: + return None + i = hits[0] + return " ".join(dw[:i] + [nw[i] + "ові"] + dw[i + 1:]) + + +RANK_DATIVE_OVI = {} +for _nominative, _cases in RANK_DECLENSIONS.items(): + _form = _dative_ovi(_nominative, _cases.get("dative", "")) + if _form: + RANK_DATIVE_OVI[_nominative] = _form + RANK_TO_NOMINATIVE.setdefault(_form.lower(), (_nominative, "dative_ovi", "male")) + # --- 2. ALL_RANK_FORMS: ВІДСОРТОВАНИЙ СПИСОК ДЛЯ РОЗПІЗНАВАННЯ --- # Список всіх можливих форм звань (усі відмінки + роди) для швидкого пошуку в тексті. # Сортування від найдовших до найкоротших критично важливе для правильного розпізнавання diff --git a/datamasking/unmasking/engine.py b/datamasking/unmasking/engine.py index 4103be7..ca51d9c 100644 --- a/datamasking/unmasking/engine.py +++ b/datamasking/unmasking/engine.py @@ -28,6 +28,7 @@ class FoundRank(TypedDict, total=False): from datamasking import _jsontext from datamasking.rank_data import ( + RANK_DATIVE_OVI, RANK_DECLENSIONS, RANK_FEMININE_MAP, RANK_DECLENSIONS_FEMALE, @@ -184,7 +185,11 @@ def unmask_ranks_gender_aware(masked_text: str, masking_map: Dict) -> Tuple[str, reconstructed_form = RANK_DECLENSIONS_FEMALE[base_female].get(case or 'nominative', base_female) else: - if original_base_form in RANK_DECLENSIONS: + if case == "dative_ovi" and original_base_form in RANK_DECLENSIONS: + # «майорові» (з 3.1.22; див. rank_data.RANK_DATIVE_OVI) + reconstructed_form = (RANK_DATIVE_OVI.get(original_base_form) + or RANK_DECLENSIONS[original_base_form]["dative"]) + elif original_base_form in RANK_DECLENSIONS: reconstructed_form = RANK_DECLENSIONS[original_base_form].get(case or 'nominative', original_base_form) reconstructed_form = _apply_original_case(base_rank_text, reconstructed_form) @@ -263,11 +268,21 @@ def unmask_other_data(masked_text: str, masking_map: Dict) -> Tuple[str, Dict]: by_lower: Dict[str, Dict[str, Dict[int, str]]] = {} for masked_value, inst_map in instance_map.items(): by_lower.setdefault(masked_value.lower(), {})[masked_value] = inst_map + # З 3.1.22 mask рахує входження без урахування регістру маски (позначка + # instance_case у mapping): тоді номери в межах «маски в будь-якому + # регістрі» унікальні, і рахувати треба так само. Старіші mapping — + # за точним регістром, як і раніше + case_insensitive = masking_map.get("instance_case") == "insensitive" def _lookup(matched: str, key: str): group = by_lower.get(key) if not group: return None, key + if case_insensitive: + merged_ci: Dict[int, str] = {} + for inst in group.values(): + merged_ci.update(inst) + return merged_ci, key if matched in group: return group[matched], matched if len(group) == 1: @@ -322,7 +337,7 @@ def _bounded(mask: str) -> str: "per-mask scan over %d masks — this is much slower on large files", e, len(instance_map), ) - return _unmask_other_data_slow(restored_text, instance_map) + return _unmask_other_data_slow(restored_text, instance_map, case_insensitive) seen: Dict[str, int] = {} # маска (точна або lower) -> скільки входжень уже зустріли segments: List[str] = [] @@ -364,11 +379,17 @@ def _bounded(mask: str) -> str: return restored_text, stats -def _unmask_other_data_slow(restored_text: str, instance_map: Dict) -> Tuple[str, Dict]: +def _unmask_other_data_slow(restored_text: str, instance_map: Dict, + case_insensitive: bool = False) -> Tuple[str, Dict]: """Запасна реалізація через find_all_occurrences (коли єдиний regex не компілюється). Семантика збігається з основним шляхом.""" stats = {"restored_count": 0, "skipped_count": 0} replacements_to_do = [] + if case_insensitive: + merged: Dict[str, Dict[int, str]] = {} + for masked_value, inst in instance_map.items(): + merged.setdefault(masked_value.lower(), {}).update(inst) + instance_map = merged for masked_value, instance_to_original in instance_map.items(): for instance_num, (start_pos, end_pos) in enumerate( find_all_occurrences(restored_text, masked_value), 1): diff --git a/datamasking/unmasking/helpers.py b/datamasking/unmasking/helpers.py index b4f16f1..4be0af6 100644 --- a/datamasking/unmasking/helpers.py +++ b/datamasking/unmasking/helpers.py @@ -198,11 +198,23 @@ def _apply_original_case(original: str, masked: str) -> str: if original.isupper(): return masked.upper() elif len(original) > 1 and original[0].isupper() and original[1:].islower(): + # Подвійне ім'я («Анна-Марія», «АННА-МАРІЯ»): кожна частина з великої, + # якщо так записано в mapping — до 3.1.22 capitalize() давав + # «Анна-марія». «генерал-майор» лишається «Генерал-майор» + parts = masked.split("-") + if len(parts) > 1 and all(p[:1].isupper() for p in parts): + return "-".join(p.capitalize() for p in parts) return masked.capitalize() elif original.islower(): return masked.lower() - # Змішаний регістр (Петренко-Іванова, McDonald) — лишаємо як у mapping; - # раніше все, що не UPPER/Title, силоміць переводилось у нижній + # Подвійне слово (маска «Доличенко-Ґубко»): регістр — по частинах, з + # тексту. До 3.1.22 бралося як у mapping, і запис-двійник з іншим + # регістром («ДОРОШЕНКО-ҐУДЗЬ» з рядка капсом) повертався капсом + o_parts, m_parts = original.split("-"), masked.split("-") + if len(o_parts) > 1 and len(o_parts) == len(m_parts) and all(o_parts) and all(m_parts): + return "-".join(_apply_original_case(o, m) for o, m in zip(o_parts, m_parts)) + # Змішаний регістр (McDonald) — лишаємо як у mapping; раніше все, що не + # UPPER/Title, силоміць переводилось у нижній return masked @@ -238,7 +250,8 @@ def extract_base_rank(full_rank_text: str) -> tuple: service_type_phrases = ['медичної служби', 'юстиції'] status_phrases = [ 'у відставці', 'в запасі', 'у запасі', - 'на пенсії', 'в резерві', 'у резерві' + 'на пенсії', 'в резерві', 'у резерві', + 'запасу', 'резерву', ] full_rank_lower = full_rank_text.lower() diff --git a/tests/test_fuzz_findings3.py b/tests/test_fuzz_findings3.py new file mode 100644 index 0000000..d08ae5d --- /dev/null +++ b/tests/test_fuzz_findings3.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Знахідки третього прогону фазера (після 3.1.21): таблиці й CSV, латинські +літери-двійники, подвійні імена, великі документи. + + - CSV з Excel («Петренко Іван Петрович;сержант;1234567890»): клітинка + після «;» без пробілу була одним словом, і по батькові лишалось відкритим; + - подвійне ім'я «Анна-Марія» розмасковувалось як «Анна-марія»; + - номери входжень маски рахувались за її регістром у першій появі, а в + тексті маска стоїть у регістрі кожного входження: коли два оригінали + мали маски, що різнились лише регістром («ГОРДІЇВНИ» / «Гордіївни»), + одне входження не розмасковувалось. Подвійне прізвище в рядку капсом + розмасковувалось капсом і в звичайному рядку; + - у великому наказі, де відкрито згадано багато звань, маска звання + збігалася з відкритим званням, і unmask підміняв його; + - «…капітаном» у кінці рядка і «СТАРШИЙ ЛЕЙТЕНАНТ …» на наступному: + «СТАРШИЙ» маскувалось як прізвище; + - латинська «o» в імені («Ярoслав»): маскою ставало справжнє ім'я + («Ярослав»), а прізвище в непрямому відмінку лишалось відкритим; + - звання «… запасу» / «… резерву» і давальний на «-ові» («майорові») + лишались відкритими. +""" +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from datamasking.masking import constants as _cfg # noqa: E402 +from datamasking.masking.declension import fold_homoglyphs # noqa: E402 +from datamasking.masking.mask_military import mask_rank # noqa: E402 +from datamasking.rank_data import RANK_DATIVE_OVI, RANK_TO_NOMINATIVE # noqa: E402 +from datamasking.unmasking.engine import unmask_text_v2 # noqa: E402 +from datamasking.unmasking.helpers import _apply_original_case, check_mapping_version # noqa: E402 +from tests.test_initials import make_masking_dict, mask # noqa: E402 + + +def _rt(text): + masked, mapping = mask(text) + restored, stats = unmask_text_v2(masked, mapping, check_mapping_version(mapping)) + assert restored == text, (masked, restored) + return masked, mapping, stats + + +def _absent(masked, *words): + for w in words: + assert w not in masked, (w, masked) + + +class TestCsv: + @pytest.mark.parametrize("text", [ + "ПІБ;Звання;ІПН\nПетренко Іван Петрович;сержант;1234567890", + "Петренко;Іван;Петрович;сержант;1234567890", + "сержант;Петренко;Іван;Петрович;1234567890", + "1;Петренко Іван Петрович;12.05.1990;сержант", + "Петренко Іван Петрович,сержант,1234567890", + ]) + def test_masked_and_separators_kept(self, text): + masked, _, _ = _rt(text) + _absent(masked, "Петренк", "Іван", "Петрович", "1234567890") + assert masked.count(";") == text.count(";") and masked.count(",") == text.count(",") + + def test_prose_semicolon_still_ends_name(self): + masked, mapping, _ = _rt("Петренко Іван; Коваль Олег Петрович") + assert "; " in masked + assert not any(";" in k for cat in mapping["mappings"].values() for k in cat) + + +class TestHyphenatedNameCase: + @pytest.mark.parametrize("text", [ + "Анна-Марія Петрівна Коваль прибула.", + "солдат Коваль Анна-Марія Петрівна", + "АННА-МАРІЯ ПЕТРІВНА КОВАЛЬ прибула; Анна-Марія Петрівна Коваль", + ]) + def test_roundtrip(self, text): + _rt(text) + + def test_rank_keeps_lower_second_part(self): + assert _apply_original_case("Полковник", "генерал-майор") == "Генерал-майор" + assert _apply_original_case("Алла", "Анна-Марія") == "Анна-Марія" + assert _apply_original_case("Алла", "АННА-МАРІЯ") == "Анна-Марія" + + def test_double_surname_case_from_text(self): + assert _apply_original_case("Доличенко-Ґубко", "ДОРОШЕНКО-ҐУДЗЬ") == "Дорошенко-Ґудзь" + assert _apply_original_case("ДОЛИЧЕНКО-ҐУБКО", "Дорошенко-Ґудзь") == "ДОРОШЕНКО-ҐУДЗЬ" + + +class TestInstanceCase: + MAPPING = { + "version": "3.1.22", + "mappings": {"patronymic": { + "петрівни": {"masked_as": "ГОРДІЇВНИ", "instances": [1, 3]}, + "богданівни": {"masked_as": "Гордіївни", "instances": [2]}, + }}, + } + + def test_new_mapping_counts_case_insensitively(self): + mapping = dict(self.MAPPING, instance_case="insensitive") + restored, stats = unmask_text_v2("ГОРДІЇВНИ, Гордіївни, Гордіївни", mapping, "v2.1") + assert restored == "ПЕТРІВНИ, Богданівни, Петрівни" + assert stats["skipped_count"] == 0 + + def test_legacy_mapping_counts_by_exact_case(self): + legacy = {"version": "2.6.5", "mappings": {"name": { + "Петро": {"masked_as": "Павло", "instances": [1, 2]}, + "ПЕТРО": {"masked_as": "ПАВЛО", "instances": [1]}, + }}} + restored, _ = unmask_text_v2("Павло ПАВЛО Павло", legacy, check_mapping_version(legacy)) + assert restored == "Петро ПЕТРО Петро" + + def test_masking_marks_mapping(self): + _, mapping, _ = _rt("сержант Петренко Іван Петрович") + assert mapping["instance_case"] == "insensitive" + + @pytest.mark.parametrize("text", [ + "ШЕВЧУК;ОКСАНИ;ПЕТРІВНИ;МОЛОДШОГО СЕРЖАНТА;2160850962\n" + "24. майора Мороз Любові Богданівни\n" + "Рапорт молодшого сержанта Шевчук Оксани Петрівни.\n", + "Дорошенку-Ґудзю П.Р. — ознайомлений\n" + "сержант ДОРОШЕНКО-ҐУДЗЬ ПЕТРО РОМАНОВИЧ\n" + "Рапорт сержанта Дорошенка-Ґудзя Петра Романовича.\n", + ]) + def test_documents(self, text): + _rt(text) + + +class TestRankMaskWhenNeighboursAreOpen: + def test_nearest_free_rank_beyond_shift(self): + hierarchy = [r.lower() for r in _cfg.ARMY_RANKS] + idx = hierarchy.index("майор") + _cfg.AVOID_RANK_MASKS = frozenset(hierarchy[idx - 2:idx + 3]) - {"майор"} + masked = mask_rank("майор", make_masking_dict(), {}) + assert masked.lower() not in _cfg.AVOID_RANK_MASKS and masked.lower() != "майор" + + def test_no_free_rank_keeps_rank(self): + _cfg.AVOID_RANK_MASKS = frozenset(r.lower() for r in _cfg.ARMY_RANKS) - {"майор"} + assert mask_rank("майор", make_masking_dict(), {}) == "майор" + + def test_document_with_many_open_ranks(self): + open_ranks = ["капітану", "майору", "лейтенанту", "старшому лейтенанту", "підполковнику", + "полковнику", "молодшому лейтенанту", "прапорщику", "старшому прапорщику"] + lines = [f"Петренко Іван Петрович доповів {r} роти про виконання." for r in open_ranks] + lines += ["капітан Коваленко Олег Олегович прибув.", "майор Мельник Тарас Петрович прибув."] + _rt("\n".join(lines)) + + +class TestRankAfterRank: + @pytest.mark.parametrize("text, kept", [ + ("Ковальським Романом Ігоровичем, 1994 р.н., капітаном\n" + "СТАРШИЙ ЛЕЙТЕНАНТ ПИЛИП'ЮК ТАРАС ОЛЕГОВИЧ.", "ПИЛИП"), + ("капітаном старшим лейтенантом Петренком", "Петренк"), + ("капітан старший лейтенант Петренко Іван Іванович", "Петренк"), + ]) + def test_second_rank_is_not_a_surname(self, text, kept): + masked, mapping, _ = _rt(text) + _absent(masked, kept) + assert not any("старш" in k.lower() for k in mapping["mappings"].get("surname", {})) + + def test_rank_word_surname_still_masked(self): + masked, _, _ = _rt("капітан Майор Іван Іванович") + _absent(masked, "Майор", "Іван") + + +class TestHomoglyphs: + def test_fold(self): + assert fold_homoglyphs("Ярoслав") == "ярослав" + assert fold_homoglyphs("John") == "john" + + @pytest.mark.parametrize("text, secrets", [ + ("Литвин Ярoслав Петрович прибув", ("Литвин", "Ярослав", "Ярoслав")), + ("сержант Литвин Ярoслав Петрович", ("Литвин", "Ярослав", "Ярoслав")), + ("Литвином Ярославoм Петровичем, 1980 р.н., капітан-лейтенантом", ("Литвином", "Ярослав")), + ("Рапорт Литвина Ярославa Петровича", ("Литвина", "Ярослав")), + ("Шановна Мaріє Іванівно!", ("Марі", "Мaрі")), + ]) + def test_masked(self, text, secrets): + masked, _, _ = _rt(text) + _absent(masked, *secrets) + + +class TestRankForms: + @pytest.mark.parametrize("text, rank", [ + ("майор запасу Петренко Іван Петрович", "майор "), + ("майора запасу Петренка Івана Петровича", "майора "), + ("старший лейтенант резерву ПЕТРЕНКО Іван Петрович", "старший лейтенант"), + ("капітан медичної служби запасу Петренко Іван Петрович", "капітан "), + ]) + def test_reserve(self, text, rank): + masked, _, _ = _rt(text) + assert not masked.startswith(rank) and (" запасу " in masked or " резерву " in masked) + + @pytest.mark.parametrize("text, rank", [ + ("Надати відпустку майорові Петренку Івану Петровичу", "майорові"), + ("оголосити подяку солдатові Коваленку Олегу Олеговичу", "подяку солдатові"), + ("старшому лейтенантові Петренку Івану Петровичу та старшому лейтенанту " + "Коваленку Олегу Олеговичу", "лейтенантові"), + ("Капітанові 1-го рангу Мельнику Тарасу Петровичу", "Капітанові"), + ("МАЙОРОВІ ПЕТРЕНКУ ІВАНУ ПЕТРОВИЧУ", "МАЙОРОВІ"), + ("рядовий Петренко Іван Петрович; рядовому Коваленку Олегу Олеговичу; " + "солдатові Мельнику Тарасу Петровичу", "; солдатові"), + ]) + def test_dative_ovi(self, text, rank): + masked, _, _ = _rt(text) + _absent(masked, rank) + assert "ові " in masked or "ОВІ " in masked + + def test_ovi_forms(self): + assert RANK_DATIVE_OVI["майор"] == "майорові" + assert RANK_DATIVE_OVI["старший лейтенант"] == "старшому лейтенантові" + assert "рядовий" not in RANK_DATIVE_OVI + assert RANK_TO_NOMINATIVE["генерал-майорові"] == ("генерал-майор", "dative_ovi", "male") diff --git a/tests/test_rank_data.py b/tests/test_rank_data.py index caa3f78..1f06bf9 100644 --- a/tests/test_rank_data.py +++ b/tests/test_rank_data.py @@ -327,7 +327,8 @@ def test_tuple_structure(self): def test_case_names_valid(self): """Тест: валідність назв відмінків""" - valid_cases = {'nominative', 'genitive', 'dative', 'instrumental'} + # dative_ovi — давальний на «-ові» («майорові», з 3.1.22) + valid_cases = {'nominative', 'genitive', 'dative', 'instrumental', 'dative_ovi'} for case_form, (base, case, gender) in RANK_TO_NOMINATIVE.items(): assert case in valid_cases, \ From a3f8b0305b7ed7d1d7e7f93ec57a4c2620764607 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 09:01:56 +0000 Subject: [PATCH 2/2] Fix legacy mapping unmasking, extras API bugs and surname stem drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by an audit of the extras API, the compatibility module and old mapping formats; each has a test in tests/test_api_findings.py. Older mappings: - Entries without instances (v2.0) restore every occurrence of their mask (ANY_INSTANCE); v2.0 ranks are restored; a mapping without "version" is classified by structure; v1 mappings are restored in one whole-word pass. Masks: - Case forms of one surname in a document share a synthetic stem: a form reuses the stem of another form already masked (surname.paradigm_mask), with endings matched to the new stem; -ин/-ін families cover the instrumental and dative; bare consonant-final surnames prefer consonant-final masks. - Surname masks use the ASCII apostrophe, so cp1251 documents save. API and CLI: - data_masking: MASK_NAMES/MASK_DATES/MASK_ORDERS also set the derived flags; apply_filter_to_globals on the shim's dict sets the engine flags. - tools: name and patronymic gender defaults to detection; empty name. - generate_password guarantees each class; length < 1 is an error. - MappingChain forward mapping, ChainUnmasker without "pass" numbers, passes setter and from_dict copying. - An empty --only/--exclude list is an error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XT6iUWaQgahXDB9TWX9Bq7 Generated-With: Claude Code 2.1.42 --- CHANGELOG.md | 52 ++++++ data_masking.py | 12 ++ datamasking/extras/password_generator.py | 29 ++-- datamasking/extras/re_mask.py | 24 +-- datamasking/extras/selective.py | 15 +- datamasking/extras/tools.py | 11 +- datamasking/masking/mask_personal.py | 12 +- datamasking/masking/surname.py | 96 ++++++++++- datamasking/unmasking/cli.py | 3 +- datamasking/unmasking/engine.py | 53 +++--- datamasking/unmasking/helpers.py | 22 ++- tests/test_api_findings.py | 204 +++++++++++++++++++++++ tests/test_unmask.py | 6 +- 13 files changed, 473 insertions(+), 66 deletions(-) create mode 100644 tests/test_api_findings.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d0aba0e..aac7c38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,58 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). When every rank of the hierarchy is open in the document, the rank is left as it is rather than masked with a rank that unmasking would confuse. +### Fixed — older mapping files +- A v2.0 mapping (entries without `instances`) restored nothing, with exit + code 0 and no warning. Such an entry now restores every occurrence of its + mask. +- Ranks in v2.0 mappings were never restored; they now are, as for 2.1+. +- A mapping without a `version` key was read as v1 and restored nothing; its + format is now taken from its structure. +- v1 mappings (plain `"original": "mask"` pairs) were restored by replacing + one mask after another. A restored value that was itself another mask was + replaced again (`Сидоренко` → `Петренко` → …), and a mask was also found + inside longer words (`Петро` inside `Петрович`). They are now restored in + one pass, whole words only. + +### Fixed — masks +- The case forms of one surname in a document now share one synthetic stem + (`Коваль`/`Коваля`/`Ковалем` → `Ковар`/`Коваря`/`Коварем`), as the + documentation promises. Surnames declined like nouns ending in a consonant + (Коваль, Ткач, Бондар, Мороз, Ґудзь) got a different stem in each case: + their case endings match those of surnames ending in `-а`/`-я`. A form now + takes the stem of another form already masked in the document. The `-ин` + and `-ін` families also cover `Литвином`, `Литвинові` and `Кузьміним`. + A bare surname ending in a consonant gets a mask ending in a consonant + where possible. +- A surname mask could contain the apostrophe `ʼ` (U+02BC) from the name + dictionary. A document in cp1251 with such a mask then failed to save. + Surname masks now use the ASCII apostrophe, as name masks already did. + +### Fixed — Python API +- `data_masking` (compatibility module): `MASK_NAMES = False` left surnames + and patronymics masked, and `MASK_DATES = False` left text dates masked; + they now switch the related flags too, as the configuration does. + `MASK_SURNAMES`, `MASK_PATRONYMICS`, `MASK_DATE_TEXT` and + `MASK_DOCUMENT_NUMBERS` can be read and set. + `apply_filter_to_globals(f, data_masking.__dict__)`, as its docstring + suggested, had no effect; it now sets the engine's flags. +- `tools.mask_name_direct`, `mask_patronymic_direct` and `mask_value` for + names and patronymics assumed a male name unless told otherwise + (`Марія` → a male name). The gender now comes from the value, as in + `data-mask`. `mask_value("", "name")` no longer fails. +- `generate_password(ensure_variety=True)` could leave out a character class + (about one short password in ten). A length below 1 is now an error instead + of an empty password, and the generator's `-l 0` / `-n 0` are rejected. +- `MappingChain.get_chain_mapping(a, b)` forward was off by one pass + (`(1, 2)` mapped the original text, not pass 1, to pass 2). + `ChainUnmasker` restored nothing for a chain whose passes have no `"pass"` + number, or after `chain.passes = [...]`. `MappingChain.from_dict` no longer + changes the dictionary it was given. + +### Fixed — command line +- `--only ,` (an empty list) enabled every type; an empty `--only` or + `--exclude` list is now an error (exit code 2). + ## [3.1.21] - 2026-11 ### Fixed — names left unmasked diff --git a/data_masking.py b/data_masking.py index 6aeb820..4e1a2ff 100644 --- a/data_masking.py +++ b/data_masking.py @@ -120,9 +120,19 @@ "MASK_NAMES", "MASK_IPN", "MASK_PASSPORT", "MASK_MILITARY_ID", "MASK_RANKS", "MASK_BRIGADES", "MASK_UNITS", "MASK_ORDERS", "MASK_BR_NUMBERS", "MASK_DATES", + "MASK_SURNAMES", "MASK_PATRONYMICS", "MASK_DATE_TEXT", "MASK_DOCUMENT_NUMBERS", "DEBUG_MODE", "PRESERVE_CASE", "HASH_ALGORITHM", }) +# Загальний прапорець вмикає/вимикає й похідні — як follow() у CLI +# (до 3.1.22 MASK_NAMES = False лишав прізвища й по батькові маскованими, +# MASK_DATES = False — текстові дати, MASK_ORDERS = False — номери довідок) +_FOLLOWERS = { + "MASK_NAMES": ("MASK_SURNAMES", "MASK_PATRONYMICS"), + "MASK_DATES": ("MASK_DATE_TEXT",), + "MASK_ORDERS": ("MASK_DOCUMENT_NUMBERS",), +} + def __getattr__(name): if name in _LIVE_FLAGS: @@ -134,6 +144,8 @@ class _LiveFlagsModule(_ModuleType): def __setattr__(self, name, value): if name in _LIVE_FLAGS: setattr(_cfg, name, value) + for follower in _FOLLOWERS.get(name, ()): + setattr(_cfg, follower, value) else: super().__setattr__(name, value) diff --git a/datamasking/extras/password_generator.py b/datamasking/extras/password_generator.py index 9fcd1b1..381c23f 100644 --- a/datamasking/extras/password_generator.py +++ b/datamasking/extras/password_generator.py @@ -158,6 +158,10 @@ def generate_password( pwd = generate_password(length=8, include_ascii_upper=False, include_ascii_lower=False, include_special=False) """ + if isinstance(length, bool) or not isinstance(length, int) or length < 1: + # До 3.1.22 довжина 0 чи від'ємна мовчки давала порожній пароль + raise ValueError(f"password length must be a positive integer, got {length!r}") + # Формуємо набір символів та список обов'язкових наборів charset = "" required_charsets: List[str] = [] @@ -200,19 +204,13 @@ def generate_password( # Гарантуємо наявність символів кожного типу (якщо довжина дозволяє) if ensure_variety: - positions_used: set = set() - for req_charset in required_charsets: - if len(positions_used) >= length: - break - # Знаходимо позицію, яка ще не використана - attempts = 0 - while attempts < length * 2: - pos = secrets.randbelow(length) - if pos not in positions_used: - password_list[pos] = secrets.choice(req_charset) - positions_used.add(pos) - break - attempts += 1 + # Різні позиції для кожного обов'язкового набору — одразу, без + # повторних спроб (до 3.1.22 випадкові спроби з лімітом інколи не + # знаходили вільної позиції: ~9 % коротких паролів без одного класу) + k = min(len(required_charsets), length) + positions = secrets.SystemRandom().sample(range(length), k) + for pos, req_charset in zip(positions, required_charsets): + password_list[pos] = secrets.choice(req_charset) return ''.join(password_list) def generate_password_from_config(config: PasswordConfig) -> Optional[str]: @@ -482,6 +480,11 @@ def main(): help='Тільки пароль, без додаткової інформації') args = parser.parse_args() + # Довжина й кількість — додатні (до 3.1.22 «-l 0» друкував порожній пароль) + if args.length < 1: + parser.error(f"--length must be a positive integer, got {args.length}") + if args.count < 1: + parser.error(f"--count must be a positive integer, got {args.count}") # Аналіз існуючого пароля if args.analyze: diff --git a/datamasking/extras/re_mask.py b/datamasking/extras/re_mask.py index 3940571..baa29b0 100644 --- a/datamasking/extras/re_mask.py +++ b/datamasking/extras/re_mask.py @@ -128,6 +128,8 @@ def passes(self) -> List[Dict]: @passes.setter def passes(self, value: List[Dict]) -> None: self._passes = value + # Інакше ChainUnmasker після chain.passes = [...] не бачив проходів + self._current_pass = len(value) @property def current_pass(self) -> int: @@ -174,6 +176,11 @@ def get_pass(self, pass_number: int) -> Optional[Dict]: for p in self._passes: if p.get("pass") == pass_number: return p + # Проходи без поля "pass" (ланцюг, зібраний вручну; його приймають і + # validate_mapping_schema, і unmask_chain) — за позицією. До 3.1.22 + # ChainUnmasker для такого ланцюга мовчки нічого не відновлював + if 1 <= pass_number <= len(self._passes) and "pass" not in self._passes[pass_number - 1]: + return self._passes[pass_number - 1] return None def get_chain_mapping(self, from_pass: int, to_pass: int) -> Dict: @@ -197,16 +204,12 @@ def get_chain_mapping(self, from_pass: int, to_pass: int) -> Dict: return self._build_reverse_chain(from_pass, to_pass) def _build_forward_chain(self, from_pass: int, to_pass: int) -> Dict: - """Compose forward mappings from from_pass to to_pass. - Chains original->masked relationships through intermediate passes.""" - # Seed with the from_pass mapping + """Compose forward mappings from the text after pass *from_pass* + (0 = original) to the text after pass *to_pass*: passes + from_pass+1 .. to_pass, as the reverse chain counts them. Before + 3.1.22 the from_pass mapping itself was included, so (1, 2) gave + original -> pass 2 instead of pass 1 -> pass 2.""" combined: Dict[str, str] = {} - seed_data = self.get_pass(from_pass) - if seed_data is not None: - for _cat, pairs in seed_data.get("mappings", {}).items(): - if isinstance(pairs, dict): - combined.update(_flat_pairs(pairs)) - # Chain through subsequent passes for step in range(from_pass + 1, to_pass + 1): step_data = self.get_pass(step) if step_data is None: @@ -276,7 +279,8 @@ def from_dict(cls, data: Dict[str, Any]) -> "MappingChain": chain_id = data.get("chain_id", _generate_chain_id()) chain = cls(chain_id=chain_id) chain._created_at = data.get("created_at", chain._created_at) - chain._passes = data.get("passes", []) + # Копія: add_pass не має змінювати словник, переданий викликачем + chain._passes = copy.deepcopy(data.get("passes", [])) chain._current_pass = len(chain._passes) return chain diff --git a/datamasking/extras/selective.py b/datamasking/extras/selective.py index a774996..d186ba4 100644 --- a/datamasking/extras/selective.py +++ b/datamasking/extras/selective.py @@ -306,6 +306,10 @@ def create_filter( """ only_set = parse_type_list(only) if only else None exclude_set = parse_type_list(exclude) if exclude else None + # «--only ,» — порожній перелік; до 3.1.22 він означав «усі типи» + for label, raw, parsed in (("only", only, only_set), ("exclude", exclude, exclude_set)): + if raw is not None and str(raw).strip() and not parsed: + raise ValueError(f"--{label}: no masking type given in {raw!r}") return SelectiveFilter(only_types=only_set, exclude_types=exclude_set) @@ -320,9 +324,16 @@ def apply_filter_to_globals( Args: selective_filter: The filter to apply. - globals_dict: The module globals dict (e.g. ``data_masking.__dict__`` - or the result of ``globals()``). + globals_dict: Where the flags live: ``vars(datamasking.masking. + constants)`` (what the engine reads) or another dict. + ``data_masking.__dict__`` (the compatibility module) + is redirected to the engine's flags: writing there + directly had no effect before 3.1.22 and left stale + values that hid the live ones. """ + if globals_dict.get("__name__") == "data_masking": + from datamasking.masking import constants as _cfg + globals_dict = vars(_cfg) # Collect which MASK_* keys should be True enabled_globals: Set[str] = set() for type_name in selective_filter.enabled_types: diff --git a/datamasking/extras/tools.py b/datamasking/extras/tools.py index d4593c3..06b6806 100644 --- a/datamasking/extras/tools.py +++ b/datamasking/extras/tools.py @@ -522,7 +522,7 @@ def mask_name_direct( value: str, masking_dict: Dict, instance_counters: Dict, - gender: str = 'male', + gender: Optional[str] = None, ) -> str: """Mask a first name, preserving case and respecting gender. @@ -534,7 +534,9 @@ def mask_name_direct( value: First name string. masking_dict: Shared masking dictionary (mutated). instance_counters: Instance tracking counters (mutated). - gender: ``'male'`` (default) or ``'female'``. + gender: ``'male'``, ``'female'`` or ``None`` (default: from the name + itself, as ``data-mask`` does; before 3.1.22 the default + was ``'male'``, and «Марія» got a male mask). Returns: Masked first name with case preserved. @@ -548,7 +550,7 @@ def mask_patronymic_direct( value: str, masking_dict: Dict, instance_counters: Dict, - gender: str = 'male', + gender: Optional[str] = None, ) -> str: """Mask a patronymic, preserving case and respecting gender. @@ -559,7 +561,8 @@ def mask_patronymic_direct( value: Patronymic string. masking_dict: Shared masking dictionary (mutated). instance_counters: Instance tracking counters (mutated). - gender: ``'male'`` (default) or ``'female'``. + gender: ``'male'``, ``'female'`` or ``None`` (default: from the + patronymic itself — «Іванівна» is female). Returns: Masked patronymic with case preserved. diff --git a/datamasking/masking/mask_personal.py b/datamasking/masking/mask_personal.py index 37c294a..36fa34b 100644 --- a/datamasking/masking/mask_personal.py +++ b/datamasking/masking/mask_personal.py @@ -24,7 +24,7 @@ generate_easy_name, same_name_forms, normalize_apostrophe, ) from datamasking.masking import surname as _surname -from datamasking.masking.surname import synthesize_surname, known_surname_forms +from datamasking.masking.surname import synthesize_surname, known_surname_forms, paradigm_mask from datamasking.masking.mask_military import _distinct_mask, _reseed @@ -136,7 +136,11 @@ def mask_surname(original: str, masking_dict: Dict, instance_counters: Dict) -> masked_parts.append(_apply_original_case(part, mp)) masked = '-'.join(masked_parts) else: - masked = _apply_original_case(original, synthesize_surname(original, forbidden=forbidden)) + # Інша форма того самого прізвища вже має маску — її основа + # (див. surname.paradigm_mask), інакше синтез + masked = _apply_original_case( + original, paradigm_mask(original, masking_dict, forbidden) + or synthesize_surname(original, forbidden=forbidden)) return add_to_mapping(masking_dict, instance_counters, "surname", original, masked) def pseudo_gender(value: str) -> str: @@ -145,7 +149,7 @@ def pseudo_gender(value: str) -> str: return 'male' if get_deterministic_seed("gender\x00" + value.lower()) % 2 == 0 else 'female' -def mask_patronymic(patronymic: str, gender: str, masking_dict: Dict, instance_counters: Dict) -> str: +def mask_patronymic(patronymic: str, gender: Optional[str], masking_dict: Dict, instance_counters: Dict) -> str: """ Маскує по батькові з урахуванням роду і відмінка. @@ -217,6 +221,8 @@ def mask_name(original: str, masking_dict: Dict, instance_counters: Dict, немає (v3.1.12). Маска стоїть у відмінку оригіналу, а всі відмінки одного імені дають одну маску. """ + if not original: + return original # БАГ #17 FIX: Зберігаємо оригінальний регістр перед обробкою is_upper = original.isupper() is_capitalize = original[0].isupper() and (len(original) == 1 or original[1:].islower()) diff --git a/datamasking/masking/surname.py b/datamasking/masking/surname.py index 3428749..68b32f2 100644 --- a/datamasking/masking/surname.py +++ b/datamasking/masking/surname.py @@ -48,7 +48,12 @@ ("евим", "ев"), ("евою", "ев"), ("евої", "ев"), ("евій", "ев"), ("ева", "ев"), ("еву", "ев"), ("ев", "ев"), ("євим", "єв"), ("євою", "єв"), ("євої", "єв"), ("євій", "єв"), ("єва", "єв"), ("єву", "єв"), ("єв", "єв"), ("івим", "ів"), ("івою", "ів"), ("івої", "ів"), ("івій", "ів"), ("іва", "ів"), ("іву", "ів"), ("ів", "ів"), - ("иним", "ин"), ("иною", "ин"), ("иної", "ин"), ("иній", "ин"), ("ина", "ин"), ("ину", "ин"), ("ин", "ин"), + # «-ином», «-инові» — орудний і давальний іменникової відміни (Литвином, + # Литвинові); до 3.1.22 вони розбирались як основа «литвин» + «-ом», і + # маска мала іншу основу, ніж «Литвин» / «Литвина» + ("инові", "ин"), ("иним", "ин"), ("ином", "ин"), ("иною", "ин"), ("иної", "ин"), ("иній", "ин"), + ("ина", "ин"), ("ину", "ин"), ("ин", "ин"), + ("іним", "ін"), ("іною", "ін"), ("іної", "ін"), ("іній", "ін"), ("іна", "ін"), ("іну", "ін"), ("ін", "ін"), # -ук / -юк / -чук / -ак / -як / -ик / -ець / -ун (приголосні основи) ("укові", "ук"), ("уком", "ук"), ("ука", "ук"), ("уку", "ук"), ("ук", "ук"), ("юкові", "юк"), ("юком", "юк"), ("юка", "юк"), ("юку", "юк"), ("юк", "юк"), @@ -162,7 +167,7 @@ def split_surname(word: str) -> Tuple[str, str, str]: # Родини, чиє закінчення приєднується до основи на приголосну: основа з # м'яким знаком/й/голосною дала б «Заєцьуком», «Журавельий» _CONSONANT_FAMILIES = frozenset({ - "енко", "ко", "ський", "цький", "ов", "ев", "єв", "ів", "ин", + "енко", "ко", "ський", "цький", "ов", "ев", "єв", "ів", "ин", "ін", "ук", "юк", "ак", "як", "ик", "ець", "єць", "ун", "ий", }) @@ -182,7 +187,10 @@ def _draw_candidates(seed: int, bare: bool) -> Iterable[Tuple[str, str]]: """ _cfg.fake_uk.seed_instance(seed) for _ in range(_FAKER_DRAWS): - candidate = _cfg.fake_uk.last_name().lower() + # Апостроф — ASCII «'», як у масках імен (v3.1.8): faker дає «ʼ» + # (U+02BC), якого немає в cp1251, і документ у cp1251 з такою маскою + # прізвища не зберігався (до 3.1.22) + candidate = _cfg.fake_uk.last_name().lower().replace("ʼ", "'").replace("’", "'") if bare: if len(candidate) >= MIN_STEM and "'" not in candidate and " " not in candidate: yield candidate, "" @@ -193,7 +201,7 @@ def _draw_candidates(seed: int, bare: bool) -> Iterable[Tuple[str, str]]: def _pick_stem(seed: int, target_len: int, family: str, ending: str, forbidden: Set[str], - prefix: str = "") -> str: + prefix: str = "", consonant_end: bool = False) -> str: """Обирає синтетичну основу: тієї ж родини, придатну до закінчення і схожої довжини, якщо є. Якщо задано *prefix* — основа починається з нього (перші символи оригінального прізвища), а решта синтетична.""" @@ -217,6 +225,12 @@ def _pick_stem(seed: int, target_len: int, family: str, ending: str, forbidden: def closest(pool): return min(pool, key=lambda s: abs(len(s) - target_len)) if pool else None + # «Голе» прізвище на приголосний (Бондар, Мороз) — маска теж на + # приголосний, якщо такий кандидат є: інші відмінки (Бондаря, Морозом) + # беруть цю основу (paradigm_mask), і «Бонуба» давала б «Бонубая» + if consonant_end: + same_family = [s for s in same_family if s[-1] in _CONSONANTS] or same_family + others = [s for s in others if s[-1] in _CONSONANTS] or others return closest(same_family) or closest(others) or _random_stem(seed, target_len, prefix) @@ -294,6 +308,76 @@ def _leaks(masked: str, original: str, stem: str) -> bool: return False +# Відміна на приголосний (Коваль, Ткач, Бондар): закінчення її відмінків +# (-а/-я, -у/-ю, -ем) збігаються з закінченнями родин «-а»/«-я» (Сорока, +# Гмиря), і без словника форма «Ткача» не відрізняється від «Сороки». Тож +# кожна форма діставала свою синтетичну основу: Коваль → Ковар, але +# Коваля → Кобіля (до 3.1.22). У межах документа форма бере основу з маски +# іншої форми того самого прізвища, якщо та вже є +_PLAIN_FAMILIES = frozenset({"", "а", "я"}) + + +def _plain_paradigm(word: str) -> Optional[Tuple[str, str]]: + """(основа без м'якого знака, закінчення) для прізвищ «простих» родин, + інакше None. «коваль» → («ковал», ""), «коваля» → («ковал», «я»).""" + stem, ending, family = split_surname(word.lower()) + if family not in _PLAIN_FAMILIES: + return None + if not ending and stem.endswith("ь"): + stem = stem[:-1] + return (stem, ending) if len(stem) >= MIN_STEM else None + + +# Закінчення відмінка — за останнім приголосним нової основи: після +# задньоязикових і губних — тверді (Ткака → Ткака, Ткаком), після шиплячих — +# «-а/-у/-ем» (Ткача, Ткачем), після інших — як в оригіналі (Коваря, Ковар) +_HARD_ONLY = frozenset("кгґхпбфвм") +_HUSHING = frozenset("жчшщ") +_TO_HARD = {"я": "а", "ю": "у", "ем": "ом", "єм": "ом", "еві": "ові"} +_TO_HUSHING = {"я": "а", "ю": "у", "ом": "ем", "єм": "ем", "ові": "еві"} + + +def _harmonize(ending: str, stem: str) -> str: + last = stem[-1:] + if last in _HARD_ONLY: + return _TO_HARD.get(ending, ending) + if last in _HUSHING: + return _TO_HUSHING.get(ending, ending) + return ending + + +def paradigm_mask(original: str, masking_dict: Dict, forbidden: Set[str]) -> Optional[str]: + """Маска (нижній регістр) для *original* з основою маски іншої форми того + самого прізвища в цьому документі — або None, якщо такої форми немає чи + похідна маска не годиться (витік, збіг зі словом документа).""" + own = _plain_paradigm(original) + if own is None: + return None + stem, ending = own + for other, info in masking_dict.get("mappings", {}).get("surname", {}).items(): + if not isinstance(info, dict) or not info.get("masked_as") or other.lower() == original.lower(): + continue + theirs = _plain_paradigm(other) + if theirs is None or theirs[0] != stem: + continue + other_mask = str(info["masked_as"]).lower() + other_ending = theirs[1] + if other_ending and not other_mask.endswith(other_ending): + continue + new_stem = other_mask[:len(other_mask) - len(other_ending)] if other_ending else other_mask + new_stem = new_stem[:-1] if new_stem.endswith("ь") else new_stem + # Основа на голосну з відмінковим закінченням — «Бонубая», «Мойдаа» + if not new_stem or "-" in new_stem or (ending and new_stem[-1] not in _CONSONANTS): + continue + masked = new_stem + _harmonize(ending, new_stem) + if original.lower().endswith("ь") and not ending and new_stem[-1:] in _CONSONANTS: + masked += "ь" # Коваль → Кобіль, як Коваля → Кобіля + if (not _leaks(masked, original, split_surname(original)[0]) + and masked not in forbidden and masked not in _document_vocab): + return masked + return None + + def known_surname_forms(masking_dict: Dict, except_original: Optional[str] = None) -> Set[str]: """Усі вже відомі оригінали та маски прізвищ (нижній регістр) — нова маска не повинна з ними збігатися, інакше unmask неоднозначний. @@ -330,7 +414,9 @@ def synthesize_surname(original: str, forbidden: Optional[Set[str]] = None, last = "" for attempt in range(_ATTEMPTS): seed = base_seed if attempt == 0 else get_deterministic_seed(f"{stem}\x00{attempt}") - new_stem = _pick_stem(seed, len(stem), family, ending, forbidden={stem}, prefix=prefix) + new_stem = _pick_stem(seed, len(stem), family, ending, forbidden={stem}, prefix=prefix, + consonant_end=(not ending and not family + and (stem[-1:] in _CONSONANTS or stem.endswith("ь")))) masked = new_stem + ending last = masked if not _leaks(masked, original, stem) and masked not in forbidden: diff --git a/datamasking/unmasking/cli.py b/datamasking/unmasking/cli.py index 1b93c55..38a05b8 100644 --- a/datamasking/unmasking/cli.py +++ b/datamasking/unmasking/cli.py @@ -115,7 +115,8 @@ def mb(section: str, key: str) -> Optional[int]: def _expected_instances(masking_map: Dict) -> int: """Скільки входжень масок записано в mapping (для ланцюга — у всіх проходах).""" maps = masking_map.get("passes", [masking_map]) if is_chain_mapping(masking_map) else [masking_map] - return sum(len(info.get("instances", [])) + # Запис без instances (mapping v2.0) — щонайменше одне входження + return sum(len(info.get("instances", [None])) for m in maps if isinstance(m, dict) for cat in m.get("mappings", {}).values() if isinstance(cat, dict) for info in cat.values() if isinstance(info, dict)) diff --git a/datamasking/unmasking/engine.py b/datamasking/unmasking/engine.py index ca51d9c..30e8cad 100644 --- a/datamasking/unmasking/engine.py +++ b/datamasking/unmasking/engine.py @@ -38,7 +38,7 @@ class FoundRank(TypedDict, total=False): from datamasking.unmasking.helpers import ( get_rank_info, check_mapping_version, - find_all_occurrences, build_instance_map, + find_all_occurrences, build_instance_map, instance_original, _apply_original_case, is_real_mask, extract_base_rank, ) @@ -139,8 +139,9 @@ def unmask_ranks_gender_aware(masked_text: str, masking_map: Dict) -> Tuple[str, instance_counters[masked_rank_lower] += 1 instance_num = instance_counters[masked_rank_lower] - if masked_rank_lower in rank_instance_map and instance_num in rank_instance_map[masked_rank_lower]: - original_rank = rank_instance_map[masked_rank_lower][instance_num] + original_rank_opt = instance_original(rank_instance_map.get(masked_rank_lower, {}), instance_num) + if original_rank_opt is not None: + original_rank = original_rank_opt if not is_real_mask(masked_rank_lower, masking_map, all_masked_ranks): stats["skipped_count"] += 1 @@ -169,8 +170,9 @@ def unmask_ranks_gender_aware(masked_text: str, masking_map: Dict) -> Tuple[str, instance_counters[base_masked_form] += 1 instance_num = instance_counters[base_masked_form] - if base_masked_form in rank_instance_map and instance_num in rank_instance_map[base_masked_form]: - original_base_form = rank_instance_map[base_masked_form][instance_num] + original_base_opt = instance_original(rank_instance_map.get(base_masked_form, {}), instance_num) + if original_base_opt is not None: + original_base_form = original_base_opt if not is_real_mask(base_masked_form, masking_map, all_masked_ranks): stats["skipped_count"] += 1 @@ -359,7 +361,7 @@ def _bounded(mask: str) -> str: continue n = seen.get(counter_key, 0) + 1 seen[counter_key] = n - original_value = inst.get(n) + original_value = instance_original(inst, n) if original_value is None: stats["skipped_count"] += 1 continue @@ -393,9 +395,9 @@ def _unmask_other_data_slow(restored_text: str, instance_map: Dict, for masked_value, instance_to_original in instance_map.items(): for instance_num, (start_pos, end_pos) in enumerate( find_all_occurrences(restored_text, masked_value), 1): - if instance_num in instance_to_original: - replacements_to_do.append( - (start_pos, end_pos, instance_to_original[instance_num], masked_value)) + original = instance_original(instance_to_original, instance_num) + if original is not None: + replacements_to_do.append((start_pos, end_pos, original, masked_value)) stats["restored_count"] += 1 else: stats["skipped_count"] += 1 @@ -434,7 +436,9 @@ def unmask_text_v2(masked_text: str, masking_map: Dict, map_version: str) -> Tup total_stats["skipped_count"] += stats.get("skipped_count", 0) return text, total_stats - if map_version == "v2.1": + # Звання — для будь-якого v2.x (до 3.1.22 лише для v2.1: у mapping v2.0 + # звання не відновлювались, бо unmask_other_data категорію rank пропускає) + if map_version.startswith("v2"): text_after_ranks, rank_stats = unmask_ranks_gender_aware(masked_text, masking_map) final_text, other_stats = unmask_other_data(text_after_ranks, masking_map) return final_text, { @@ -449,23 +453,28 @@ def unmask_text_v1(masked_text: str, masking_map: Dict) -> Tuple[str, Dict]: """ Unmask для старих версій 1.x (проста заміна рядків). """ - restored_text = masked_text stats = {"restored_count": 0, "skipped_count": 0} - all_replacements = [] + originals: Dict[str, str] = {} for category, cat_mappings in masking_map.get("mappings", {}).items(): for original, masked in cat_mappings.items(): - if isinstance(masked, str): - all_replacements.append((masked, original)) - - all_replacements.sort(key=lambda x: len(x[0]), reverse=True) - - for masked, original in all_replacements: - if masked in restored_text: - stats["restored_count"] += restored_text.count(masked) - restored_text = restored_text.replace(masked, original) + if isinstance(masked, str) and masked: + originals.setdefault(masked, original) + if not originals: + return masked_text, stats + + # Один прохід, цілими словами, довші маски першими. До 3.1.22 маски + # замінювались по черзі через str.replace: відновлений оригінал, що сам + # був маскою іншого запису, замінювався ще раз («Сидоренко» → «Петренко» + # → …), а маска «Петро» знаходилась усередині «Петрович» + alternatives = sorted(originals, key=len, reverse=True) + pattern = re.compile(r"(? str: + stats["restored_count"] += 1 + return originals[match.group(0)] - return restored_text, stats + return pattern.sub(_replace, masked_text), stats def unmask_json_with_stats(masked_data: Any, masking_map: Dict, map_version: str) -> Tuple[Any, Dict]: diff --git a/datamasking/unmasking/helpers.py b/datamasking/unmasking/helpers.py index 4be0af6..8f8dc46 100644 --- a/datamasking/unmasking/helpers.py +++ b/datamasking/unmasking/helpers.py @@ -127,9 +127,11 @@ def check_mapping_version(masking_map: Dict) -> str: Returns: Версія логіки: 'v2.1', 'v2.0' або 'v1' """ - version = masking_map.get("version", "1.0.0") + # Без ключа version — за структурою (до 3.1.22 тут підставлялось «1.0.0», + # і mapping 2.x/3.x без version ішов у v1 і нічого не відновлював) + version = masking_map.get("version") - match = re.match(r"v?(\d+)\.(\d+)", str(version)) + match = re.match(r"v?(\d+)\.(\d+)", str(version)) if version is not None else None if not match: # Невідома/відсутня версія — визначаємо за структурою: записи-об'єкти # з masked_as — це v2.1 (до 3.1.10 такий mapping ішов у v1 і нічого @@ -164,11 +166,23 @@ def find_all_occurrences(text: str, pattern: str) -> List[Tuple[int, int]]: return occurrences +# Номер «будь-якого входження» для записів без instances (див. build_instance_map) +ANY_INSTANCE = 0 + + +def instance_original(instances: Dict[int, str], number: int) -> Optional[str]: + """Оригінал для входження *number* маски (або для будь-якого — ANY_INSTANCE).""" + value = instances.get(number) + return value if value is not None else instances.get(ANY_INSTANCE) + + def build_instance_map(masking_map: Dict) -> Dict[str, Dict[int, str]]: """ Створює мапу для instance tracking при unmask. - Структура: {masked_value: {instance_num: original_value}} + Структура: {masked_value: {instance_num: original_value}}. Запис без + ключа instances (mapping v2.0) — номер ANY_INSTANCE: оригінал для + будь-якого входження цієї маски (до 3.1.22 такий запис не відновлювався). """ instance_map: Dict[str, Dict[int, str]] = {} mappings_data = masking_map.get("mappings", {}) @@ -177,7 +191,7 @@ def build_instance_map(masking_map: Dict) -> Dict[str, Dict[int, str]]: for original, mask_info in mappings.items(): if isinstance(mask_info, dict) and "masked_as" in mask_info: masked_value = mask_info["masked_as"] - instances = mask_info.get("instances", []) + instances = mask_info.get("instances", [ANY_INSTANCE]) if masked_value not in instance_map: instance_map[masked_value] = {} diff --git a/tests/test_api_findings.py b/tests/test_api_findings.py new file mode 100644 index 0000000..6bf1a7c --- /dev/null +++ b/tests/test_api_findings.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Знахідки аудиту API (datamasking.extras, shim-и, старі формати mapping) і +узгодженості масок прізвищ (після 3.1.21). + + - mapping v2.0 без instances нічого не відновлював; у mapping v2.0 не + відновлювались звання; mapping без ключа version читався як v1; + - v1 (str.replace) замінював відновлене ще раз і частини слів; + - shim data_masking: MASK_NAMES = False лишав прізвища й по батькові + маскованими; apply_filter_to_globals(…, data_masking.__dict__) нічого не + робив; + - tools: рід імені й по батькові за замовчуванням — чоловічий; + mask_value("", "name") падав; + - generate_password(ensure_variety=True) інколи пропускав клас символів; + довжина 0 давала порожній пароль; + - MappingChain.get_chain_mapping уперед — зсув на прохід; ChainUnmasker + не бачив проходів без поля "pass"; from_dict змінював словник викликача; + - --only , вмикав усі типи; + - відмінки одного прізвища на приголосний діставали різні синтетичні + основи (Коваль → Ковар, Коваля → Кобіля); + - маска прізвища з «ʼ» (U+02BC) не зберігалась у документі cp1251. +""" +import os +import string +import sys +import warnings +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from datamasking.extras import tools # noqa: E402 +from datamasking.extras.password_generator import generate_password # noqa: E402 +from datamasking.extras.re_mask import ChainUnmasker, MappingChain # noqa: E402 +from datamasking.extras.selective import apply_filter_to_globals, create_filter # noqa: E402 +from datamasking.masking import constants as _cfg # noqa: E402 +from datamasking.masking.engine import mask_text_context_aware # noqa: E402 +from datamasking.masking.mask_personal import mask_surname # noqa: E402 +from datamasking.unmasking.engine import unmask_text_v1, unmask_text_v2 # noqa: E402 +from datamasking.unmasking.helpers import check_mapping_version # noqa: E402 +from tests.test_initials import make_masking_dict, mask # noqa: E402 + + +class TestLegacyMappings: + def test_v20_without_instances(self): + m = {"version": "2.0.1", "mappings": {"surname": {"Іванов": {"masked_as": "Петренко", "gender": "M"}}}} + restored, stats = unmask_text_v2("Петренко і Петренко", m, check_mapping_version(m)) + assert restored == "Іванов і Іванов" and stats["restored_count"] == 2 + + def test_v20_ranks(self): + m = {"version": "2.0.1", "mappings": { + "rank": {"капітан": {"masked_as": "майор", "instances": [1, 2]}}, + "surname": {"Іванов": {"masked_as": "Петренко", "instances": [1]}}, + "name": {"Петро": {"masked_as": "Андрій", "instances": [1]}}}} + restored, _ = unmask_text_v2("Майор Петренко Андрій і майор", m, check_mapping_version(m)) + assert restored == "Капітан Іванов Петро і капітан" + + def test_no_version_is_detected_by_structure(self): + m = {"mappings": {"surname": {"Іванов": {"masked_as": "Петренко", "instances": [1]}}}} + assert check_mapping_version(m) == "v2.1" + assert check_mapping_version({"mappings": {"surname": {"Іванов": "Петренко"}}}) == "v1" + + def test_v1_single_pass_whole_words(self): + m = {"mappings": {"surname": {"Сидоренко": "Коваленко", "Петренко": "Сидоренко"}, + "name": {"Олег": "Петро"}}} + restored, stats = unmask_text_v1("Сидоренко Петро Петрович і Коваленко", m) + assert restored == "Петренко Олег Петрович і Сидоренко" + assert stats["restored_count"] == 3 + + +class TestShim: + @pytest.fixture + def dm(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import data_masking + return data_masking + + def test_mask_names_false_covers_surnames(self, dm): + dm.MASK_NAMES = False + assert dm.MASK_SURNAMES is False and dm.MASK_PATRONYMICS is False + assert "Петренко Іван Петрович" in mask("сержант Петренко Іван Петрович")[0] + + def test_mask_dates_false_covers_text_dates(self, dm): + dm.MASK_DATES = False + assert mask("«28» вересня 2025 року")[0] == "«28» вересня 2025 року" + + def test_filter_on_shim_dict(self, dm): + apply_filter_to_globals(create_filter(only="ipn"), dm.__dict__) + assert _cfg.MASK_NAMES is False and _cfg.MASK_IPN is True + assert "MASK_NAMES" not in dm.__dict__ and dm.MASK_NAMES is False + + +class TestTools: + @pytest.mark.parametrize("value, kind, first", [ + ("Марія", "name", "М"), ("Наталія", "name", "Н"), + ]) + def test_female_name_by_default(self, value, kind, first): + out = tools.mask_value(value, kind, make_masking_dict(), {}) + assert out[0] == first and out[-1] in "аяь" + + def test_female_patronymic_by_default(self): + assert tools.mask_value("Іванівна", "patronymic", make_masking_dict(), {}).endswith("івна") + + def test_explicit_gender_still_works(self): + assert tools.mask_value("Іванович", "patronymic", make_masking_dict(), {}, gender="male").endswith("вич") + + def test_empty_name(self): + assert tools.mask_value("", "name", make_masking_dict(), {}) == "" + + +class TestPassword: + def test_variety_guaranteed(self): + for _ in range(3000): + p = generate_password(length=4) + assert any(c in string.ascii_uppercase for c in p) + assert any(c in string.ascii_lowercase for c in p) + assert any(c in string.digits for c in p) + + @pytest.mark.parametrize("length", [0, -3, True, 2.5]) + def test_bad_length(self, length): + with pytest.raises(ValueError): + generate_password(length=length) + + +class TestChains: + @pytest.fixture + def passes(self): + text = "сержант Петренко Іван Петрович, ІПН 1234567890" + states, out = [text], [] + cur = text + for _ in range(3): + d, c = make_masking_dict(), {} + cur = mask_text_context_aware(cur, d, c) + d["instance_tracking"] = c + out.append(d) + states.append(cur) + return out, states + + def test_unmasker_without_pass_field(self, passes): + data, states = passes + raw = {"passes": data} + chain = MappingChain.from_dict(raw) + assert ChainUnmasker(chain).unmask_all(states[-1]) == states[0] + assert ChainUnmasker(chain).unmask_to_version(states[-1], 1) == states[1] + chain.add_pass(make_masking_dict()) + assert len(raw["passes"]) == 3 + + def test_passes_setter(self, passes): + data, states = passes + chain = MappingChain() + chain.passes = data + assert chain.current_pass == 3 + assert ChainUnmasker(chain).unmask_all(states[-1]) == states[0] + + def test_forward_is_inverse_of_reverse(self, passes): + data, _ = passes + chain = MappingChain() + for d in data: + chain.add_pass(d) + forward, reverse = chain.get_chain_mapping(1, 2), chain.get_chain_mapping(2, 1) + assert forward and all(reverse.get(v) == k for k, v in forward.items()) + + +class TestSelectiveEmpty: + @pytest.mark.parametrize("only, exclude", [(",", None), (" , ", None), (None, ",")]) + def test_empty_list_is_an_error(self, only, exclude): + with pytest.raises(ValueError, match="no masking type"): + create_filter(only=only, exclude=exclude) + + +class TestSurnameStems: + @pytest.mark.parametrize("forms", [ + ("Коваль", "Коваля", "Ковалю", "Ковалем"), + ("Коваля", "Коваль", "Ковалем"), + ("Ткач", "Ткача", "Ткачу", "Ткачем"), + ("Бондар", "Бондаря", "Бондарю", "Бондарем"), + ("Мороз", "Мороза", "Морозу", "Морозом"), + ("Ґудзь", "Ґудзя", "Ґудзем"), + ("Литвин", "Литвина", "Литвину", "Литвином", "Литвинові"), + ("Кузьмін", "Кузьміна", "Кузьміну", "Кузьміним"), + ]) + def test_one_stem_per_document(self, forms): + d = make_masking_dict() + masks = [mask_surname(f, d, {}).lower() for f in forms] + assert len(os.path.commonprefix(masks)) >= 4, masks + + def test_document(self): + text = ("солдат Коваль Олег Петрович\nРапорт солдата Коваля Олега Петровича\n" + "солдату Ковалю О.П.\nз солдатом Ковалем Олегом Петровичем\n") + masked, mapping = mask(text) + masks = [info["masked_as"].lower() for info in mapping["mappings"]["surname"].values()] + assert len(os.path.commonprefix(masks)) >= 4, masks + restored, _ = unmask_text_v2(masked, mapping, check_mapping_version(mapping)) + assert restored == text + + @pytest.mark.parametrize("surname", ["Кузьміним", "Мар'яненко", "Кузьмінський"]) + def test_ascii_apostrophe(self, surname): + out = mask_surname(surname, make_masking_dict(), {}) + assert "ʼ" not in out and "’" not in out + out.encode("cp1251") diff --git a/tests/test_unmask.py b/tests/test_unmask.py index e7e0d3f..9fea177 100644 --- a/tests/test_unmask.py +++ b/tests/test_unmask.py @@ -203,9 +203,11 @@ def test_no_instances(self): } } + # Mapping v2.0 не мав instances: запис діє для будь-якого входження + # маски (ANY_INSTANCE); до 3.1.22 такий запис не відновлювався + from datamasking.unmasking.helpers import ANY_INSTANCE instance_map = build_instance_map(masking_map) - assert "Іваненко" in instance_map - assert instance_map["Іваненко"] == {} + assert instance_map == {"Іваненко": {ANY_INSTANCE: "петренко"}} class TestApplyOriginalCaseUnmask: