PD Guard: модуль безопасности персональных данных

This commit is contained in:
Onbehalfofme
2026-09-22 17:43:33 +03:00
commit 41052fac6a
80 changed files with 91230 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
target
.git
.idea
*.iml
models/*
+5
View File
@@ -0,0 +1,5 @@
target/
.idea/
*.iml
.kilo/
models/
+102
View File
@@ -0,0 +1,102 @@
# Основной вариант — JVM: прогретая, она вдвое экономнее native по процессору и
# втрое быстрее по задержке. Холодное окно закрывает прогрев на старте.
# docker compose up pd-guard
#
# Наблюдение поднимается вместе со всем стеком: `docker compose up` даёт сервис,
# Prometheus на 9090 и Grafana на 3000 с уже заведённым дашбордом.
#
# Несколько узлов: маскирование детерминировано и работает на любом узле, а вот
# обратный шаг требует общего состояния — иначе запрос попадёт не на тот узел.
# docker compose --profile cluster up
services:
pd-guard:
image: pd-guard-spring:jvm
build:
context: .
dockerfile: src/main/docker/Dockerfile
ports:
- "8080:8080"
environment:
PDGUARD_MAX_CONCURRENT: "2000"
PDGUARD_STORE_TTL_MINUTES: "30"
# Прогоны на старте, чтобы первые запросы не шли по непрогретому коду.
PDGUARD_WARMUP_ITERATIONS: "2000"
# Вторая ступень распознавания. Модель в репозиторий не входит:
# ./tools/fetch-ner-model.sh. Пока её нет, ступень сама выключится и
# сервис работает на одних правилах.
PDGUARD_NER_ENGINE: rubert
PDGUARD_NER_MODEL: /deployments/models/rubert-ner
volumes:
- ./config:/deployments/config:ro
- ./models:/deployments/models:ro
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 10s
timeout: 2s
retries: 3
prometheus:
image: prom/prometheus:v2.54.1
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.retention.time=6h
- --web.enable-lifecycle
ports:
- "9090:9090"
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
depends_on:
- pd-guard
grafana:
image: grafana/grafana:11.2.0
ports:
- "3000:3000"
environment:
# Демонстрационный стенд: вход без пароля, чтобы жюри не искало учётные данные.
# Для контура с реальными данными это надо снять.
GF_AUTH_ANONYMOUS_ENABLED: "true"
GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
GF_AUTH_DISABLE_LOGIN_FORM: "true"
GF_USERS_DEFAULT_THEME: light
# Сразу открывать дашборд модуля, а не пустую главную.
GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH: /var/lib/grafana/dashboards/pd-guard.json
volumes:
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
depends_on:
- prometheus
redis:
profiles: ["cluster"]
image: redis:7-alpine
command: ["redis-server", "--save", "", "--appendonly", "no", "--maxmemory", "1gb", "--maxmemory-policy", "allkeys-lru"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 2s
retries: 5
node-a: &node
profiles: ["cluster"]
build:
context: .
dockerfile: src/main/docker/Dockerfile
ports:
- "8081:8080"
environment:
PDGUARD_STORE_BACKEND: redis
SPRING_DATA_REDIS_HOST: redis
PDGUARD_MAX_CONCURRENT: "2000"
PDGUARD_WARMUP_ITERATIONS: "2000"
volumes:
- ./config:/deployments/config:ro
depends_on:
redis:
condition: service_healthy
node-b:
<<: *node
ports:
- "8082:8080"
+31
View File
@@ -0,0 +1,31 @@
{
"default": {
"enabled": true,
"demask": true,
"maskMode": "MASK",
"types": ["*"],
"requireCompanion": [
"CVV", "PIN", "DATE", "BIRTH_PLACE", "ADDRESS_COUNTRY",
"ACCOUNT_NUMBER", "BIK", "OGRN", "OGRNIP", "KPP",
"INCOME", "BIOMETRIC"
]
},
"crm": {
"enabled": true,
"demask": false,
"maskMode": "TOKEN",
"types": ["FIO", "PHONE", "EMAIL", "ADDRESS_CITY", "ADDRESS_STREET", "ADDRESS_HOUSE", "ADDRESS_FLAT"]
},
"analytics": {
"enabled": true,
"demask": false,
"maskMode": "SYNTHETIC",
"types": ["*"]
},
"legacy-billing": {
"enabled": false,
"demask": false,
"maskMode": "MASK",
"types": ["*"]
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
apiVersion: 1
providers:
- name: pd-guard
orgId: 1
folder: ""
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: false
@@ -0,0 +1,11 @@
apiVersion: 1
datasources:
- name: Prometheus
uid: PDGUARD_PROM
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
jsonData:
timeInterval: 5s
+20
View File
@@ -0,0 +1,20 @@
# Сбор метрик модуля. Интервал короткий: прогоны нагрузки идут минутами, и на
# пятнадцати секундах картина смазывается.
global:
scrape_interval: 5s
evaluation_interval: 5s
scrape_configs:
- job_name: pd-guard
metrics_path: /actuator/prometheus
static_configs:
- targets: ["pd-guard:8080"]
labels:
instance: "один узел"
# Узлы кластерного профиля. Пока он не поднят, цели просто числятся
# недоступными и на остальной сбор это не влияет.
- job_name: pd-guard-cluster
metrics_path: /actuator/prometheus
static_configs:
- targets: ["node-a:8080", "node-b:8080"]
+104
View File
@@ -0,0 +1,104 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
<relativePath/>
</parent>
<groupId>ru.pdguard</groupId>
<artifactId>pd-guard-spring</artifactId>
<version>1.0.0</version>
<name>pd-guard-spring</name>
<description>Модуль безопасности персональных данных — Spring Boot</description>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<onnxruntime.version>1.20.0</onnxruntime.version>
<sonar.host.url>http://localhost:9000</sonar.host.url>
<sonar.projectKey>pd-guard-spring</sonar.projectKey>
<sonar.projectName>pd-guard-spring</sonar.projectName>
<sonar.java.source>21</sonar.java.source>
<sonar.java.target>21</sonar.java.target>
<sonar.sources>src/main/java</sonar.sources>
<sonar.tests>src/test/java</sonar.tests>
<sonar.java.binaries>target/classes</sonar.java.binaries>
<sonar.java.libraries>target/dependency/*.jar</sonar.java.libraries>
<sonar.junit.reportsPath>target/surefire-reports</sonar.junit.reportsPath>
<sonar.coverage.jacoco.xmlReportPaths>target/site/jacoco/jacoco.xml</sonar.coverage.jacoco.xmlReportPaths>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.microsoft.onnxruntime</groupId>
<artifactId>onnxruntime</artifactId>
<version>${onnxruntime.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.sonarsource.scanner.maven</groupId>
<artifactId>sonar-maven-plugin</artifactId>
<version>3.11.0.3922</version>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
+16
View File
@@ -0,0 +1,16 @@
# Основной вариант развёртывания: прогретая JVM вдвое экономнее native по процессору
# и втрое быстрее по задержке. Холодное окно закрывает прогрев на старте.
# mvn package && docker build -f src/main/docker/Dockerfile -t pd-guard-spring:jvm .
FROM eclipse-temurin:21-jre
WORKDIR /deployments
COPY --chown=1000:1000 target/pd-guard-spring-1.0.0.jar /deployments/app.jar
COPY --chown=1000:1000 config /deployments/config
EXPOSE 8080
USER 1000
ENV JAVA_OPTS="-Dspring.config.additional-location=optional:file:/deployments/config/"
ENV PDGUARD_SYSTEMS_FILE=/deployments/config/systems.json
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /deployments/app.jar"]
@@ -0,0 +1,19 @@
package ru.pdguard;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Точка входа Spring Boot приложения.
*
* <p>Модуль безопасности персональных данных: прокси между системой-потребителем
* и LLM. Находит персональные данные, маскирует их и восстанавливает исходный
* текст на обратном шаге.
*/
@SpringBootApplication
public class PdGuardApplication {
public static void main(String[] args) {
SpringApplication.run(PdGuardApplication.class, args);
}
}
@@ -0,0 +1,40 @@
package ru.pdguard.api;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.config.SystemsConfig;
import ru.pdguard.detect.RuleRegistry;
import java.util.List;
import java.util.Map;
/** Просмотр действующих настроек и принудительное их перечитывание. */
@RestController
public class AdminResource {
private final SystemsConfig systems;
private final RuleRegistry registry;
public AdminResource(SystemsConfig systems, RuleRegistry registry) {
this.systems = systems;
this.registry = registry;
}
@GetMapping("/admin/config")
public Map<String, SystemPolicy> config() {
return systems.current();
}
@GetMapping("/admin/types")
public List<String> types() {
return registry.knownTypes();
}
@PostMapping("/admin/reload")
public Map<String, SystemPolicy> reload() {
systems.reload();
return systems.current();
}
}
@@ -0,0 +1,14 @@
package ru.pdguard.api;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/** Проба готовности для балансировщика и проверяющей системы. */
@RestController
public class HealthResource {
@GetMapping("/health")
public String health() {
return "OK";
}
}
@@ -0,0 +1,133 @@
package ru.pdguard.api;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.config.SystemsConfig;
import ru.pdguard.core.AdaptiveConcurrencyLimiter;
import ru.pdguard.core.Pipeline;
/**
* Единственная точка входа контракта: маскирование и демаскирование по
* {@code payload_id}.
*
* <p>Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка
* нет или система неизвестна — применяются настройки {@code default}, поэтому
* контракт работает и без него. Система, выключенная в настройках, получает
* {@code 403}.
*
* <p>При перегрузке отвечает {@code 429} с {@code Retry-After}. Порог перегрузки —
* не фиксированное число запросов, а задержка обработки: {@link AdaptiveConcurrencyLimiter}
* сам находит потолок конкурентности под то, сколько CPU реально досталось контейнеру,
* вместо того чтобы копить запросы и упереться в таймаут вызывающей стороны.
*/
@RestController
public class ProcessResource {
private static final Logger LOG = LoggerFactory.getLogger(ProcessResource.class);
/** Заголовок, которым система-потребитель себя называет. */
public static final String SYSTEM_HEADER = "X-System-Id";
/** Общий секрет системы. Проверяется, только если он задан в настройках. */
public static final String KEY_HEADER = "X-System-Key";
/** Имя метрики отклонённых запросов и имя её метки причины. */
private static final String REJECTED_METRIC = "pdguard.requests.rejected";
private static final String REASON_TAG = "reason";
/**
* Что отдаётся при внутреннем сбое. Ни одного знака из запроса: сбой на прямом
* шаге иначе выпустил бы наружу незамаскированные персональные данные.
*/
static final String PROCESSING_UNAVAILABLE = "[обработка недоступна]";
public record ProcessRequest(
@JsonProperty("payload") String payload,
@JsonProperty("payload_id") String payloadId) {
}
public record ProcessResponse(@JsonProperty("result") String result) {
}
private final Pipeline pipeline;
private final SystemsConfig systems;
private final AdaptiveConcurrencyLimiter limiter;
private final Counter rejected;
private final Counter malformed;
private final Counter forbidden;
private final Counter failed;
public ProcessResource(Pipeline pipeline, SystemsConfig systems, MeterRegistry meters,
@Value("${pdguard.min-concurrent:8}") int minConcurrent,
@Value("${pdguard.max-concurrent:2000}") int maxConcurrent,
@Value("${pdguard.target-latency-ms:200}") long targetLatencyMillis) {
this.pipeline = pipeline;
this.systems = systems;
this.limiter = new AdaptiveConcurrencyLimiter(minConcurrent, maxConcurrent, targetLatencyMillis);
this.rejected = meters.counter(REJECTED_METRIC, REASON_TAG, "overload");
this.malformed = meters.counter(REJECTED_METRIC, REASON_TAG, "malformed");
this.forbidden = meters.counter(REJECTED_METRIC, REASON_TAG, "system_disabled");
this.failed = meters.counter(REJECTED_METRIC, REASON_TAG, "internal_error");
meters.gauge("pdguard.concurrency.limit", limiter, AdaptiveConcurrencyLimiter::limit);
meters.gauge("pdguard.concurrency.in.flight", limiter, AdaptiveConcurrencyLimiter::inFlight);
}
@PostMapping("/process")
public ResponseEntity<ProcessResponse> process(@RequestBody(required = false) ProcessRequest request,
@RequestHeader(value = SYSTEM_HEADER, required = false) String systemId,
@RequestHeader(value = KEY_HEADER, required = false) String systemKey) {
if (request == null || request.payload() == null
|| request.payloadId() == null || request.payloadId().isBlank()) {
malformed.increment();
return ResponseEntity.badRequest()
.body(new ProcessResponse("payload и payload_id обязательны"));
}
SystemPolicy policy = systems.policyFor(systemId);
if (!policy.accepts(systemKey)) {
forbidden.increment();
LOG.warn("Системе {} отказано: неверный ключ", systemId);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ProcessResponse("Неверный ключ системы"));
}
if (!policy.enabled()) {
forbidden.increment();
LOG.warn("Системе {} обращение в модуль запрещено настройками", systemId);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ProcessResponse("Системе " + systemId + " обращение в модуль запрещено"));
}
if (!limiter.tryAcquire()) {
rejected.increment();
return ResponseEntity.status(429).header("Retry-After", "1").build();
}
long started = System.nanoTime();
try {
String result = pipeline.process(request.payload(), request.payloadId(), policy);
return ResponseEntity.ok(new ProcessResponse(result));
} catch (RuntimeException e) {
// Ни 5xx, ни исходный текст. Пять подряд невалидных ответов останавливают
// прогон, поэтому код остаётся 200 — но возвращать при сбое сам payload
// нельзя: на прямом шаге наружу ушли бы незамаскированные ПД, ровно то,
// ради чего сервис и существует. Ответ фиксированный: он ничего не
// раскрывает и не выглядит порчей данных.
failed.increment();
LOG.error("payload_id={} обработка не удалась, отдан безопасный ответ",
request.payloadId(), e);
return ResponseEntity.ok(new ProcessResponse(PROCESSING_UNAVAILABLE));
} finally {
limiter.release(System.nanoTime() - started);
}
}
}
@@ -0,0 +1,103 @@
package ru.pdguard.api;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.config.SystemsConfig;
import ru.pdguard.core.LlmClient;
import ru.pdguard.core.Pipeline;
import java.util.Map;
/**
* Демонстрационное плечо к языковой модели: показывает всю цепочку целиком.
*
* <pre>
* потребитель → маскирование → LLM → демаскирование → потребитель
* </pre>
*
* <p>В ответе видны все три текста — что ушло в модель, что она вернула и что
* получил потребитель. Это и есть доказательство, что в модель не попало ничего
* незамаскированного, а ответ вернулся с восстановленными значениями.
*
* <p>Ответ модели — другой текст, а не тот же самый, поэтому восстановить его по
* идентификатору целиком нельзя: замена идёт пофрагментно. Звёздочки для этого не
* годятся — одна и та же маска отвечала бы разным значениям, — поэтому здесь всегда
* применяется обратимая подстановка, независимо от режима маскирования системы.
*
* <p>Контракт проверяющей системы это плечо не затрагивает: он живёт в
* {@link ProcessResource}.
*/
@RestController
public class ProxyResource {
private static final Logger LOG = LoggerFactory.getLogger(ProxyResource.class);
public record ProxyRequest(@JsonProperty("prompt") String prompt) {
}
public record ProxyResponse(
@JsonProperty("prompt_masked") String promptMasked,
@JsonProperty("llm_response_masked") String llmResponseMasked,
@JsonProperty("response") String response,
@JsonProperty("replaced") Map<String, String> replaced,
@JsonProperty("llm") String llm,
@JsonProperty("error") String error) {
}
private final Pipeline pipeline;
private final SystemsConfig systems;
private final LlmClient llm;
public ProxyResource(Pipeline pipeline, SystemsConfig systems, LlmClient llm) {
this.pipeline = pipeline;
this.systems = systems;
this.llm = llm;
}
@PostMapping("/proxy")
public ResponseEntity<ProxyResponse> proxy(@RequestBody(required = false) ProxyRequest request,
@RequestHeader(value = ProcessResource.SYSTEM_HEADER, required = false) String systemId,
@RequestHeader(value = ProcessResource.KEY_HEADER, required = false) String systemKey) {
if (request == null || request.prompt() == null || request.prompt().isBlank()) {
return ResponseEntity.badRequest()
.body(new ProxyResponse(null, null, null, null, null, "поле prompt обязательно"));
}
SystemPolicy policy = systems.policyFor(systemId);
if (!policy.accepts(systemKey)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ProxyResponse(null, null, null, null, null, "Неверный ключ системы"));
}
if (!policy.enabled()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ProxyResponse(null, null, null, null, null,
"Системе " + systemId + " обращение в модуль запрещено"));
}
Pipeline.Masked masked = pipeline.maskWithRestorations(request.prompt(), policy);
LlmClient.Answer answer = llm.ask(masked.text());
String restored = policy.demask() ? restore(answer.text(), masked.restorations()) : answer.text();
LOG.info("proxy: система={} заменено={} модель={}",
policy.name(), masked.restorations().size(), answer.source());
return ResponseEntity.ok(new ProxyResponse(masked.text(), answer.text(), restored,
masked.restorations(), answer.source(), null));
}
/** Возвращает исходные значения на место подстановок в ответе модели. */
private static String restore(String text, Map<String, String> restorations) {
String result = text;
for (Map.Entry<String, String> entry : restorations.entrySet()) {
result = result.replace(entry.getKey(), entry.getValue());
}
return result;
}
}
@@ -0,0 +1,25 @@
package ru.pdguard.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import ru.pdguard.detect.NameDictionary;
/**
* Конфигурация словарей распознавания ФИО.
*
* <p>{@link NameDictionary} работает через статические методы и не требует
* экземпляра, но путь к внешнему файлу денилиста задаётся из настроек при
* старте. Бин здесь нужен только для того, чтобы Spring подставил значение
* {@code pdguard.well-known-file} и передал его словарю.
*/
@Configuration
public class DictionaryConfiguration {
@Bean
public NameDictionary nameDictionary(
@Value("${pdguard.well-known-file:config/well-known.txt}") String wellKnownFile) {
NameDictionary.configure(wellKnownFile);
return NameDictionary.create();
}
}
@@ -0,0 +1,80 @@
package ru.pdguard.config;
import ru.pdguard.mask.MaskMode;
import java.util.Set;
/**
* Правила обработки для одной системы-потребителя.
*
* @param name имя системы; им же разделяется хранилище соответствий,
* чтобы одна система не могла достать данные другой
* @param enabled разрешено ли системе обращаться в модуль
* @param demask выполняется ли для системы обратное преобразование
* @param maskMode вид замены: звёздочки, токен или синтетическое значение
* @param types типы ПД к маскированию; {@code "*"} — все известные
* @param key общий секрет системы; задан — заголовок {@code X-System-Key} обязан
* совпасть, иначе имя системы можно было бы просто назвать.
* Только знаки ASCII: заголовки HTTP передаются в Latin-1,
* и кириллица в ключе до сервиса доедет искажённой
* @param requireCompanion типы, которые маскируются только вместе с ПД другого типа:
* пин-код сам по себе безвреден, пин-код рядом с номером
* карты — уже нет; то же для даты без якорного слова, места
* рождения («Нижний Новгород» в рассказе о городе — не адрес
* клиента) и страны («цены выросли в Казахстане» — не гражданство).
* Сюда же банковские реквизиты — счёт, БИК, ОГРН, ОГРНИП, КПП:
* сами по себе они опознают организацию или счёт, а не человека,
* и в перечне типов из задания их нет. Рядом с именем клиента
* они становятся его данными и маскируются.
* Сюда же доход и биометрия. Сумма заработка без человека —
* статистика («доход домохозяйств вырос до 74 500 руб»), а не
* персональные данные. Биометрия же в тексте не встречается
* вовсе: это шаблон в базе, и правило маскирует лишь само
* упоминание, то есть слово, а не данные. Чувствителен здесь
* факт, что биометрию сдал названный человек, — а он и
* существует только при имени рядом
*/
public record SystemPolicy(String name, boolean enabled, boolean demask, MaskMode maskMode,
Set<String> types, Set<String> requireCompanion, String key) {
public static final String ALL = "*";
/** Имя политики по умолчанию; оно же разделяет хранилище для запросов без заголовка. */
public static final String DEFAULT_NAME = "default";
/** Политика по умолчанию: маскируем всё, что умеем, обратное преобразование включено. */
public static final SystemPolicy DEFAULT = new SystemPolicy(
DEFAULT_NAME, true, true, MaskMode.MASK, Set.of(ALL),
Set.of("CVV", "PIN", "DATE", "BIRTH_PLACE", "ADDRESS_COUNTRY",
"ACCOUNT_NUMBER", "BIK", "OGRN", "OGRNIP", "KPP",
"INCOME", "BIOMETRIC"), null);
public SystemPolicy {
types = Set.copyOf(types);
requireCompanion = Set.copyOf(requireCompanion);
}
/** Политика только для перечисленных типов, с остальными настройками по умолчанию. */
public static SystemPolicy forTypes(String... types) {
return new SystemPolicy(DEFAULT_NAME, true, true, MaskMode.MASK,
Set.of(types), DEFAULT.requireCompanion(), null);
}
/** Совпадает ли предъявленный ключ. Ключ не задан — проверка не применяется. */
public boolean accepts(String presentedKey) {
if (key == null || key.isBlank()) {
return true;
}
return java.security.MessageDigest.isEqual(
key.getBytes(java.nio.charset.StandardCharsets.UTF_8),
(presentedKey == null ? "" : presentedKey).getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
public boolean allows(String type) {
return types.contains(ALL) || types.contains(type);
}
public boolean needsCompanion(String type) {
return requireCompanion.contains(type);
}
}
@@ -0,0 +1,136 @@
package ru.pdguard.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import ru.pdguard.mask.MaskMode;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.Set;
import java.util.TreeMap;
/**
* Список систем, которым разрешено обращаться в модуль, и правила для каждой.
*
* <p>Читается из внешнего файла, чтобы настройки менялись без пересборки. Файл
* перечитывается сам, когда меняется время его изменения; проверка выполняется
* не чаще раза в секунду, чтобы не ходить в файловую систему на каждом запросе.
* Файла нет — работают настройки по умолчанию, и сервис поднимается без него.
*/
@Component
public class SystemsConfig {
private static final Logger LOG = LoggerFactory.getLogger(SystemsConfig.class);
/** Имя политики, которая применяется к запросам без заголовка системы. */
public static final String DEFAULT_SYSTEM = "default";
private static final long RECHECK_MILLIS = 1000;
/** Описание одной системы в файле настроек. */
public record SystemEntry(Boolean enabled, Boolean demask, String maskMode,
List<String> types, List<String> requireCompanion, String key) {
}
private final Path file;
private final ObjectMapper mapper;
private final AtomicReference<Map<String, SystemPolicy>> policies =
new AtomicReference<>(Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT));
private volatile long fileTimestamp;
private volatile long lastCheck;
public SystemsConfig(@Value("${pdguard.systems-file:config/systems.json}") String path,
ObjectMapper mapper) {
this.file = Path.of(path);
this.mapper = mapper;
reload();
}
/** Правила для системы; неизвестная система получает настройки по умолчанию. */
public SystemPolicy policyFor(String systemId) {
refreshIfChanged();
Map<String, SystemPolicy> current = policies.get();
SystemPolicy policy = systemId == null ? null : current.get(systemId);
if (policy != null) {
return policy;
}
return current.getOrDefault(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
}
/** Известна ли система по имени. */
public boolean isKnown(String systemId) {
refreshIfChanged();
return systemId != null && policies.get().containsKey(systemId);
}
/** Текущие настройки — для отдачи в административном интерфейсе. */
public Map<String, SystemPolicy> current() {
refreshIfChanged();
return new TreeMap<>(policies.get());
}
/** Перечитать файл настроек немедленно. */
public final synchronized void reload() {
lastCheck = System.currentTimeMillis();
if (!Files.isReadable(file)) {
LOG.info("Файл настроек {} не найден, применяются настройки по умолчанию", file.toAbsolutePath());
policies.set(Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT));
fileTimestamp = 0;
return;
}
try {
fileTimestamp = Files.getLastModifiedTime(file).toMillis();
Map<String, SystemEntry> entries = mapper.readValue(Files.readAllBytes(file),
mapper.getTypeFactory().constructMapType(TreeMap.class, String.class, SystemEntry.class));
Map<String, SystemPolicy> parsed = new TreeMap<>();
entries.forEach((name, entry) -> parsed.put(name, toPolicy(name, entry)));
parsed.putIfAbsent(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
policies.set(Map.copyOf(parsed));
LOG.info("Настройки систем перечитаны из {}: {}", file.toAbsolutePath(), parsed.keySet());
} catch (IOException | IllegalArgumentException e) {
// Битый файл не должен ронять работающий сервис: остаются прежние настройки.
LOG.error("Не удалось прочитать {}, продолжаем с прежними настройками", file.toAbsolutePath(), e);
}
}
private void refreshIfChanged() {
long now = System.currentTimeMillis();
if (now - lastCheck < RECHECK_MILLIS) {
return;
}
lastCheck = now;
try {
if (!Files.isReadable(file)) {
return;
}
if (Files.getLastModifiedTime(file).toMillis() != fileTimestamp) {
reload();
}
} catch (IOException e) {
LOG.debug("Не удалось проверить время изменения {}", file, e);
}
}
private static SystemPolicy toPolicy(String name, SystemEntry entry) {
SystemPolicy base = SystemPolicy.DEFAULT;
Set<String> types = entry.types() == null ? base.types() : new HashSet<>(entry.types());
Set<String> companions = entry.requireCompanion() == null
? base.requireCompanion() : new HashSet<>(entry.requireCompanion());
MaskMode mode = entry.maskMode() == null
? base.maskMode() : MaskMode.valueOf(entry.maskMode().toUpperCase(Locale.ROOT));
return new SystemPolicy(name,
entry.enabled() == null || entry.enabled(),
entry.demask() == null || entry.demask(),
mode, types, companions, entry.key());
}
}
@@ -0,0 +1,129 @@
package ru.pdguard.core;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.TimeUnit;
/**
* Предел одновременных запросов, который сам подстраивается под задержку,
* а не задан фиксированным числом. Растёт, пока обработка укладывается в
* целевое время, и сжимается, как только перестаёт — вместо того чтобы
* копить очередь и подходить к таймауту вызывающей стороны.
*
* <p>Число CPU контейнеру намеренно не спрашивается: {@code Runtime.
* availableProcessors()} под квотой {@code --cpus} в cgroups не меняется
* (это не affinity, а квота), поэтому в контейнере с долей ядра оно
* показывает все ядра хоста и как источник предела не годится. Задержка —
* наблюдаемое следствие реальной доли CPU, а не догадка о её размере.
*
* <p>Шаг регулировки привязан к времени, не к числу запросов: при первой
* версии предел менялся на каждый завершённый запрос, и на высоком RPS
* тысячи «быстрых» замеров прилетали за миллисекунды — предел успевал
* разогнаться до потолка ещё до того, как перегрузка вообще проявлялась,
* и то же самое повторялось после каждого восстановления. Проверено
* нагрузочным тестом: без привязки к времени p95 на перегрузке доходил
* до 1,8–2,3 с при 0,5 CPU, хотя предел вроде бы должен был сжаться.
* Не чаще, чем раз в {@link #ADJUST_WINDOW_NANOS}, предел меняется одним
* шагом на основе среднего за окно — так скорость регулировки не зависит
* от того, насколько высок входящий RPS.
*
* <p>Рост — на единицу за окно (AIMD), не удвоением. Удвоение (slow start
* из TCP) здесь не подходит: там обратная связь — RTT, миллисекунды, и
* лишний виток роста стоит дёшево. Здесь обратная связь — время ответа
* заявки, и под перегрузкой оно само составляет секунды: предел успевает
* удвоиться несколько раз (2→4→8→…→сотни) быстрее, чем придёт первый
* сигнал о деградации, и уже принятые заявки не исчезают из очереди, даже
* если следующим окном предел тут же обрушить. Проверено нагрузочным
* тестом: с удвоением p95 на перегрузке всё равно доходил до 1,8–2,2 с.
* Линейный рост копит риск медленно, и первый плохой сигнал останавливает
* его на порядок раньше. Сжатие — вдвое, а не на единицу: на перегрузке
* дешевле один раз отрезать с запасом, чем несколько окон подряд плавно
* подходить к безопасному уровню, пока заявки продолжают копиться.
*
* <p>ponytail: счётчики окна суммируются без блокировки — гонка на границе
* окна может добавить образец в уже подводимый итог или отбросить один,
* не больше; при масштабах в десятки-сотни образцов на окно это не видно.
* Нужен точный регулятор — взять готовую библиотеку вроде Netflix
* {@code concurrency-limits} (Vegas/Gradient2); здесь она не взята из
* осторожности к GraalVM native-image: незнакомая рефлексия в чужой
* библиотеке — это ровно тот класс проблем, из-за которого модели второй
* ступени понадобилась отдельная настройка сборки.
*/
public class AdaptiveConcurrencyLimiter {
private static final long DEFAULT_ADJUST_WINDOW_NANOS = TimeUnit.MILLISECONDS.toNanos(20);
private final AtomicInteger inFlight = new AtomicInteger();
private final AtomicLong windowSumNanos = new AtomicLong();
private final AtomicInteger windowSamples = new AtomicInteger();
private final AtomicLong lastAdjustNanos;
private final int minLimit;
private final int maxLimit;
private final long targetLatencyNanos;
private final long adjustWindowNanos;
private final AtomicInteger limit;
public AdaptiveConcurrencyLimiter(int minLimit, int maxLimit, long targetLatencyMillis) {
this(minLimit, maxLimit, targetLatencyMillis, DEFAULT_ADJUST_WINDOW_NANOS);
}
/** Настраиваемое окно регулировки — для тестов, которым реальные 20мс на шаг не подходят. */
AdaptiveConcurrencyLimiter(int minLimit, int maxLimit, long targetLatencyMillis, long adjustWindowNanos) {
if (minLimit < 1 || maxLimit < minLimit) {
throw new IllegalArgumentException("Некорректные границы предела: " + minLimit + ".." + maxLimit);
}
this.minLimit = minLimit;
this.maxLimit = maxLimit;
this.targetLatencyNanos = TimeUnit.MILLISECONDS.toNanos(targetLatencyMillis);
this.adjustWindowNanos = adjustWindowNanos;
this.limit = new AtomicInteger(minLimit);
this.lastAdjustNanos = new AtomicLong(System.nanoTime());
}
/** {@code true} — запрос принят; вызывающая сторона обязана вызвать {@link #release}. */
public boolean tryAcquire() {
if (inFlight.incrementAndGet() > limit.get()) {
inFlight.decrementAndGet();
return false;
}
return true;
}
/** Освобождает слот; предел подстраивается не чаще раза в окно, а не на каждый вызов. */
public void release(long elapsedNanos) {
inFlight.decrementAndGet();
windowSumNanos.addAndGet(elapsedNanos);
windowSamples.incrementAndGet();
long now = System.nanoTime();
long last = lastAdjustNanos.get();
if (now - last >= adjustWindowNanos && lastAdjustNanos.compareAndSet(last, now)) {
adjust();
}
}
private void adjust() {
int samples = windowSamples.getAndSet(0);
long sum = windowSumNanos.getAndSet(0);
if (samples == 0) {
return;
}
long avg = sum / samples;
if (avg < targetLatencyNanos) {
limit.set(Math.min(maxLimit, limit.get() + 1));
} else {
limit.set(Math.max(minLimit, limit.get() / 2));
}
}
/** Сколько запросов обрабатывается прямо сейчас — для наблюдения. */
public int inFlight() {
return inFlight.get();
}
/** Текущий предел — для метрики, чтобы деградацию было видно, а не только чувствовать по 429. */
public int limit() {
return limit.get();
}
}
@@ -0,0 +1,102 @@
package ru.pdguard.core;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Optional;
/**
* Обращение к языковой модели для демонстрационного плеча.
*
* <p>Адрес не задан — работает заглушка: она возвращает присланный текст обратно.
* Для демонстрации этого достаточно, потому что проверяется не качество ответа
* модели, а то, что в модель ушёл замаскированный текст, а потребителю вернулся
* восстановленный.
*
* <p>Модель недоступна или ответила ошибкой — плечо деградирует до той же заглушки,
* а причина попадает в ответ и в журнал. Ронять запрос из-за внешнего сервиса нельзя.
*/
@Component
public class LlmClient {
private static final Logger LOG = LoggerFactory.getLogger(LlmClient.class);
/** Что вернула модель и кто именно ответил. */
public record Answer(String text, String source) {
}
private final Optional<String> url;
private final Optional<String> apiKey;
private final String model;
private final Duration timeout;
private final HttpClient http;
private final ObjectMapper mapper = new ObjectMapper();
public LlmClient(
@Value("${pdguard.llm.url:}") String url,
@Value("${pdguard.llm.api-key:}") String apiKey,
@Value("${pdguard.llm.model:gpt-4o-mini}") String model,
@Value("${pdguard.llm.timeout-seconds:20}") int timeoutSeconds) {
this.url = Optional.ofNullable(url).filter(value -> !value.isBlank());
this.apiKey = Optional.ofNullable(apiKey).filter(value -> !value.isBlank());
this.model = model;
this.timeout = Duration.ofSeconds(timeoutSeconds);
this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
if (this.url.isEmpty()) {
LOG.info("Адрес языковой модели не задан, плечо работает на заглушке");
}
}
public Answer ask(String maskedPrompt) {
if (url.isEmpty()) {
return new Answer(stub(maskedPrompt), "заглушка");
}
try {
return new Answer(call(maskedPrompt), model);
} catch (Exception e) {
LOG.error("Языковая модель недоступна, плечо ответило заглушкой", e);
return new Answer(stub(maskedPrompt), "заглушка: модель недоступна");
}
}
/**
* Ответ содержит присланный текст целиком: так на демонстрации видно, что
* подстановки вернулись на свои места при обратном преобразовании.
*/
private static String stub(String maskedPrompt) {
return "Ответ по запросу: " + maskedPrompt;
}
private String call(String maskedPrompt) throws Exception {
ObjectNode body = mapper.createObjectNode();
body.put("model", model);
ObjectNode message = body.putArray("messages").addObject();
message.put("role", "user");
message.put("content", maskedPrompt);
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(url.get()))
.timeout(timeout)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
mapper.writeValueAsString(body), StandardCharsets.UTF_8));
apiKey.ifPresent(key -> request.header("Authorization", "Bearer " + key));
HttpResponse<String> response = http.send(request.build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() / 100 != 2) {
throw new IllegalStateException("модель ответила " + response.statusCode());
}
return mapper.readTree(response.body())
.path("choices").path(0).path("message").path("content").asText();
}
}
@@ -0,0 +1,65 @@
package ru.pdguard.core;
import io.micrometer.core.instrument.Meter;
import io.micrometer.core.instrument.config.MeterFilter;
import io.micrometer.core.instrument.distribution.DistributionStatisticConfig;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
/**
* Настройка распределений для метрик времени.
*
* <p>По умолчанию Micrometer отдаёт по таймеру только сумму, количество и максимум.
* Этого хватает на среднее, но не на перцентили, а именно они описывают SLA: важно
* не среднее время ответа, а то, сколько запросов уложилось в срок. Гистограмма
* добавляет ряды по корзинам, и {@code histogram_quantile} в Prometheus считает по
* ним p50, p95 и p99.
*
* <p>Границы корзин заданы явно и подобраны под наши задержки: от четверти
* миллисекунды до десяти секунд. Без явных границ Micrometer создаёт их сам и
* заметно больше, а каждая корзина — это отдельный временной ряд на каждое
* сочетание меток.
*/
@Configuration
public class MetricsConfiguration {
/** Целевая задержка из критериев оценки: ориентир, а не жёсткий предел. */
private static final Duration SLA_TARGET = Duration.ofMillis(500);
private static final Duration[] BOUNDARIES = {
Duration.ofNanos(250_000), Duration.ofMillis(1), Duration.ofMillis(5),
Duration.ofMillis(10), Duration.ofMillis(25), Duration.ofMillis(50),
Duration.ofMillis(100), Duration.ofMillis(250), SLA_TARGET,
Duration.ofSeconds(1), Duration.ofSeconds(2), Duration.ofSeconds(5),
Duration.ofSeconds(10)
};
@Bean
public MeterFilter histogramsForTimers() {
return new MeterFilter() {
@Override
public DistributionStatisticConfig configure(Meter.Id id, DistributionStatisticConfig config) {
if (!needsHistogram(id.getName())) {
return config;
}
double[] boundaries = new double[BOUNDARIES.length];
for (int i = 0; i < BOUNDARIES.length; i++) {
boundaries[i] = BOUNDARIES[i].toNanos();
}
return DistributionStatisticConfig.builder()
.percentilesHistogram(false)
.serviceLevelObjectives(boundaries)
.build()
.merge(config);
}
};
}
private static boolean needsHistogram(String name) {
return "pdguard.process".equals(name)
|| "pdguard.ner.duration".equals(name)
|| "http.server.requests".equals(name);
}
}
@@ -0,0 +1,197 @@
package ru.pdguard.core;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicLong;
/**
* Соответствие «исходный текст ↔ маска», по которому выполняется демаскирование.
*
* <p>Два индекса: по {@code payload_id} — основной путь, и по отпечатку маски —
* страховка на случай, если идентификатор до сервиса не доехал.
*
* <p>Оба индекса разделены по системам-потребителям. Индекс по отпечатку ищет
* совпадение по самому тексту запроса, и без такого разделения он превращался бы в
* способ достать чужие данные: маски детерминированы и низкоэнтропийны, поэтому,
* прислав «Клиент И. И. И., паспорт 45** ****56», можно было бы получить в ответ
* исходные значения из запроса другого потребителя. Разделение ограничивает это
* пределами одной системы, которая и так видит свои данные.
*
* <p>Хранилище ограничено по суммарному объёму строк, а записи живут ограниченное
* время: персональные данные не должны залёживаться в памяти, а крупные тексты не
* должны исчерпать кучу. Вытеснение идёт в порядке добавления и выполняется прямо
* на записи — отдельного потока и внешней библиотеки кеширования не требуется.
*
* <p>Когда включён общий слой ({@link SharedIndex}), соответствие пишется ещё и туда,
* а чтение при промахе по локальной памяти идёт в него. Это нужно при работе на
* нескольких узлах: обратный запрос легко попадает не на тот узел, который выполнял
* прямой. Локальная память при этом остаётся первым уровнем, и обычный путь
* обходится без обращения по сети.
*/
@Component
public class PayloadStore {
/** Сколько протухших записей просматривается за одну операцию записи. */
private static final int SWEEP_PER_PUT = 4;
/** Пара «исходный текст — маска» с отпечатком, владельцем и сроком жизни. */
public record Entry(String system, String original, String masked,
String fingerprint, long expiresAt) {
boolean alive(long now) {
return now < expiresAt;
}
int weight() {
return original.length() + masked.length();
}
}
private final Map<String, Entry> byId = new ConcurrentHashMap<>();
private final Map<String, Entry> byMaskFingerprint = new ConcurrentHashMap<>();
private final ConcurrentLinkedQueue<String> insertionOrder = new ConcurrentLinkedQueue<>();
private final AtomicLong charsHeld = new AtomicLong();
private final long maxChars;
private final long ttlMillis;
private final SharedIndex shared;
@Autowired
public PayloadStore(
@Value("${pdguard.store.max-chars:134217728}") long maxChars,
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
SharedIndex shared) {
this.maxChars = maxChars;
this.ttlMillis = ttlMinutes * 60_000L;
this.shared = shared;
}
/** Конструктор для тестов: только локальная память, общий слой выключен. */
public PayloadStore(long maxChars, int ttlMinutes) {
this(maxChars, ttlMinutes, SharedIndex.disabled());
}
public void put(String system, String payloadId, String original, String masked) {
long now = System.currentTimeMillis();
Entry entry = new Entry(system, original, masked, fingerprint(masked), now + ttlMillis);
String idKey = scoped(system, payloadId);
Entry replaced = byId.put(idKey, entry);
byMaskFingerprint.put(scoped(system, entry.fingerprint()), entry);
insertionOrder.add(idKey);
charsHeld.addAndGet((long) entry.weight() - (replaced == null ? 0 : replaced.weight()));
sweepExpired(now);
evictWhileOverLimit();
shared.put(system, payloadId, original, masked, entry.fingerprint());
}
public Entry byId(String system, String payloadId) {
String idKey = scoped(system, payloadId);
Entry entry = byId.get(idKey);
if (entry != null && entry.alive(System.currentTimeMillis())) {
return entry;
}
if (entry != null) {
forget(idKey, entry);
}
SharedIndex.SharedEntry fromShared = shared.byId(system, payloadId);
if (fromShared == null) {
return null;
}
// Соседний узел уже выполнял прямой шаг: забираем соответствие к себе,
// чтобы повторное обращение обошлось без сети.
put(system, payloadId, fromShared.original(), fromShared.masked());
return byId.get(idKey);
}
/**
* Исходный текст по самой маске — когда {@code payload_id} не совпал. Поиск идёт
* только в пределах той же системы: чужую маску подобрать и обменять на исходные
* данные нельзя.
*/
public String originalForMask(String system, String masked) {
String fingerprint = fingerprint(masked);
Entry entry = byMaskFingerprint.get(scoped(system, fingerprint));
if (entry != null && entry.alive(System.currentTimeMillis())) {
return entry.original();
}
return shared.originalForFingerprint(system, fingerprint);
}
/** Сколько символов сейчас удерживается — для диагностики и тестов. */
public long charsHeld() {
return charsHeld.get();
}
/**
* Ключ, однозначно разделяющий системы. Длина имени в начале снимает вопрос о
* разделителе: имя системы может содержать любые знаки.
*/
private static String scoped(String system, String key) {
String owner = system == null ? "" : system;
return owner.length() + ":" + owner + ":" + key;
}
/** Убирает протухшие записи с головы очереди, не более нескольких за раз. */
private void sweepExpired(long now) {
for (int i = 0; i < SWEEP_PER_PUT; i++) {
String oldest = insertionOrder.peek();
if (oldest == null) {
return;
}
Entry entry = byId.get(oldest);
if (entry == null) {
insertionOrder.poll();
continue;
}
if (entry.alive(now)) {
return;
}
insertionOrder.poll();
forget(oldest, entry);
}
}
private void evictWhileOverLimit() {
while (charsHeld.get() > maxChars) {
String oldest = insertionOrder.poll();
if (oldest == null) {
return;
}
Entry entry = byId.get(oldest);
if (entry != null) {
// ponytail: если тот же payload_id записали повторно, в очереди остался
// старый след и здесь вытесняется свежая запись. Цена — одно лишнее
// обращение к маскированию; точный учёт потребовал бы двусвязного списка.
forget(oldest, entry);
}
}
}
private void forget(String idKey, Entry entry) {
if (byId.remove(idKey, entry)) {
byMaskFingerprint.remove(scoped(entry.system(), entry.fingerprint()), entry);
charsHeld.addAndGet(-entry.weight());
}
}
private static String fingerprint(String value) {
try {
MessageDigest sha = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(sha.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 недоступен в этой среде выполнения", e);
}
}
}
+291
View File
@@ -0,0 +1,291 @@
package ru.pdguard.core;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.NameDictionary;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span;
import ru.pdguard.mask.MaskContext;
import ru.pdguard.mask.Masker;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;
/**
* Обработка одного обращения: поиск ПД, маскирование и обратное преобразование.
*
* <p>Направление определяется по {@code payload_id}, а не по содержимому запроса:
* <ul>
* <li>идентификатор неизвестен — маскируем;</li>
* <li>пришёл ранее выданный нами текст маски — возвращаем исходный текст;</li>
* <li>пришёл тот же исходный текст — возвращаем ту же маску, что и в первый раз.</li>
* </ul>
* Последний случай — повторная попытка проверяющей системы: ответ обязан
* совпасть с первым, иначе демаскирование по этому элементу развалится.
*/
@Component
public class Pipeline {
private static final Logger LOG = LoggerFactory.getLogger(Pipeline.class);
/** Грубая оценка числа токенов по числу символов — для метрики TPS. */
private static final int CHARS_PER_TOKEN = 4;
private final RuleRegistry registry;
private final Masker masker;
private final PayloadStore store;
private final MeterRegistry meters;
private final NameCascade cascade;
private final Counter tokensProcessed;
@Autowired
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, MeterRegistry meters,
NameCascade cascade) {
this.registry = registry;
this.masker = masker;
this.store = store;
this.meters = meters;
this.cascade = cascade;
meters.gauge("pdguard.store.chars", store, PayloadStore::charsHeld);
this.tokensProcessed = Counter.builder("pdguard.tokens.processed")
.description("Оценка числа обработанных токенов, для расчёта TPS")
.register(meters);
}
/** Конструктор для тестов: метрики никуда не отдаются, вторая ступень выключена. */
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store) {
this(registry, masker, store, new SimpleMeterRegistry(), NameCascade.disabled());
}
/** Конструктор для тестов второй ступени. */
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, NameCascade cascade) {
this(registry, masker, store, new SimpleMeterRegistry(), cascade);
}
public String process(String payload, String payloadId, SystemPolicy policy) {
long started = System.nanoTime();
tokensProcessed.increment((double) payload.length() / CHARS_PER_TOKEN);
PayloadStore.Entry known = store.byId(policy.name(), payloadId);
if (known != null) {
if (policy.demask() && payload.equals(known.masked())) {
LOG.debug("payload_id={} обратное преобразование по идентификатору", payloadId);
recordLatency("unmask", policy.name(), started);
return known.original();
}
if (payload.equals(known.original())) {
LOG.debug("payload_id={} повторная попытка, отдаём прежнюю маску", payloadId);
recordLatency("mask", policy.name(), started);
return known.masked();
}
}
if (policy.demask()) {
String original = store.originalForMask(policy.name(), payload);
if (original != null) {
LOG.debug("payload_id={} обратное преобразование по отпечатку маски", payloadId);
recordLatency("unmask", policy.name(), started);
return original;
}
}
return mask(payload, payloadId, policy, started);
}
/**
* Длительность обработки с разрезом по направлению и системе-потребителю.
* Метрики берутся из реестра по тегам: систем немного и они заданы настройками,
* поэтому разрастания рядов не будет, а разрез по потребителям виден сразу.
*/
private void recordLatency(String direction, String system, long startedNanos) {
Timer.builder("pdguard.process")
.description("Длительность обработки обращения")
.tag("direction", direction)
.tag("system", system)
.register(meters)
.record(System.nanoTime() - startedNanos, TimeUnit.NANOSECONDS);
}
/**
* Фрагменты, которые будут замаскированы: поиск по правилам, разрешение
* перекрытий и все отсечения. Отдельный метод нужен, чтобы качество детекции
* можно было измерить, не разбирая замаскированный текст обратно.
*/
public List<Span> findPersonalData(String text, SystemPolicy policy) {
List<Span> spans = resolveOverlaps(registry.detect(text, policy));
if (policy.allows(RuleRegistry.FIO)) {
// Вторая ступень разбирает только то, что не покрыла первая.
spans = resolveOverlaps(cascade.addMissedNames(text, spans));
}
spans = dropOrganisationNames(text, spans);
spans = dropWellKnownNames(text, spans);
return dropLonelyCompanions(spans, policy);
}
private String mask(String payload, String payloadId, SystemPolicy policy, long started) {
List<Span> spans = findPersonalData(payload, policy);
String masked = apply(payload, spans, policy);
store.put(policy.name(), payloadId, payload, masked);
recordLatency("mask", policy.name(), started);
logFindings(policy.name(), payloadId, payload.length(), spans);
return masked;
}
/**
* Оставляет непересекающиеся фрагменты: при конфликте побеждает более
* приоритетный, при равном приоритете — более длинный.
*/
static List<Span> resolveOverlaps(List<Span> spans) {
List<Span> candidates = new ArrayList<>(spans);
candidates.sort(Comparator.comparingInt(Span::priority).reversed()
.thenComparing(Comparator.comparingInt(Span::length).reversed())
.thenComparingInt(Span::start));
// Принятые фрагменты не пересекаются и упорядочены по началу, поэтому
// кандидату достаточно сверить себя с ближайшим слева и ближайшим справа.
// Перебор всех принятых давал бы квадрат: на тексте в сотню тысяч токенов
// фрагментов набираются тысячи.
NavigableMap<Integer, Span> accepted = new TreeMap<>();
for (Span candidate : candidates) {
if (overlapsAccepted(accepted, candidate)) {
continue;
}
accepted.put(candidate.start(), candidate);
}
return List.copyOf(accepted.values());
}
/** Проверяет, пересекается ли кандидат с ближайшим принятым слева или справа. */
private static boolean overlapsAccepted(NavigableMap<Integer, Span> accepted, Span candidate) {
Map.Entry<Integer, Span> before = accepted.floorEntry(candidate.start());
if (before != null && before.getValue().overlaps(candidate)) {
return true;
}
Map.Entry<Integer, Span> after = accepted.ceilingEntry(candidate.start());
return after != null && after.getValue().overlaps(candidate);
}
/**
* Убирает имена, стоящие в названиях организаций и объектов на карте:
* «Институт Склифосовского», «Музей Тропинина», «улица Королёва». Проверка
* не зависит от того, есть ли в тексте другие ПД: слово перед именем решает
* само по себе.
*/
static List<Span> dropOrganisationNames(String text, List<Span> spans) {
return spans.stream()
.filter(span -> !RuleRegistry.FIO.equals(span.type())
|| !NameDictionary.precededByOrganisation(text, span.start()))
.toList();
}
/**
* Убирает имена известных людей: «стихи Александра Пушкина» персональными
* данными не являются. Если же в тексте есть ПД другого типа, речь идёт о
* конкретном человеке, и имя остаётся замаскированным — однофамилец
* исторической фигуры защиту не теряет.
*/
static List<Span> dropWellKnownNames(String text, List<Span> spans) {
boolean otherPersonalDataPresent = spans.stream()
.anyMatch(span -> !RuleRegistry.FIO.equals(span.type()));
if (otherPersonalDataPresent) {
return spans;
}
return spans.stream()
.filter(span -> !RuleRegistry.FIO.equals(span.type())
|| !NameDictionary.isWellKnown(text.substring(span.start(), span.end())))
.toList();
}
/**
* Убирает типы, которые опасны только в сочетании с другими ПД.
* Пин-код в отрыве от номера карты не является персональными данными,
* рядом с номером карты — является.
*
* <p>Спутником считается только находка самостоятельного типа. Раньше здесь
* сравнивалось число различных типов, и два спутника заверяли друг друга:
* «Оплата 01.02.2025, ОГРН 1027700132195» маскировалась целиком, хотя человека
* в тексте нет, а дата и ОГРН по отдельности персональными данными не являются.
* Сочетание двух несамостоятельных типов самостоятельным не становится.
*/
static List<Span> dropLonelyCompanions(List<Span> spans, SystemPolicy policy) {
for (Span span : spans) {
if (!policy.needsCompanion(span.type())) {
return spans;
}
}
// Дошли сюда — самостоятельных находок нет, а значит все оставшиеся спутники одиноки.
return List.of();
}
/** Замаскированный текст вместе с таблицей обратной замены. */
public record Masked(String text, java.util.Map<String, String> restorations) {
}
/**
* Маскирует текст и отдаёт таблицу обратной замены.
*
* <p>Нужно для прокси к языковой модели: ответ модели — другой текст, и восстановить
* его целиком по идентификатору нельзя, замену приходится делать пофрагментно.
* Звёздочки для этого не годятся — одна и та же маска может отвечать разным
* значениям, — поэтому режим замены здесь всегда обратимый.
*/
public Masked maskWithRestorations(String text, SystemPolicy policy) {
SystemPolicy reversible = new SystemPolicy(policy.name(), policy.enabled(), policy.demask(),
ru.pdguard.mask.MaskMode.TOKEN, policy.types(), policy.requireCompanion(), policy.key());
List<Span> spans = findPersonalData(text, reversible);
if (spans.isEmpty()) {
return new Masked(text, java.util.Map.of());
}
MaskContext context = new MaskContext();
String masked = apply(text, spans, reversible, context);
logFindings(policy.name(), "proxy", text.length(), spans);
return new Masked(masked, context.restorations());
}
private String apply(String text, List<Span> spans, SystemPolicy policy) {
return apply(text, spans, policy, new MaskContext());
}
private String apply(String text, List<Span> spans, SystemPolicy policy, MaskContext context) {
if (spans.isEmpty()) {
return text;
}
StringBuilder sb = new StringBuilder(text.length());
int cursor = 0;
for (Span span : spans) {
sb.append(text, cursor, span.start());
String value = text.substring(span.start(), span.end());
sb.append(masker.mask(span.type(), value, policy.maskMode(), context));
cursor = span.end();
}
sb.append(text, cursor, text.length());
return sb.toString();
}
/**
* В журнал и в метрики попадают только идентификатор, типы ПД и их количество.
* Сами значения не логируются ни на одном уровне.
*/
private void logFindings(String system, String payloadId, int length, List<Span> spans) {
Map<String, Integer> counts = new LinkedHashMap<>();
for (Span span : spans) {
counts.merge(span.type(), 1, Integer::sum);
}
counts.forEach((type, count) ->
meters.counter("pdguard.pd.detected", "type", type, "system", system).increment(count));
LOG.info("payload_id={} символов={} найдено={}", payloadId, length, counts);
}
}
@@ -0,0 +1,79 @@
package ru.pdguard.core;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.MaskMode;
import ru.pdguard.mask.Masker;
import java.util.Set;
/**
* Прогон обработки на старте, чтобы первые запросы не попадали на непрогретый код.
*
* <p>На JVM разница измерима: без прогрева первые десятки секунд нагрузки идут по
* интерпретируемому и наспех скомпилированному коду, и p95 оказывается примерно
* вдесятеро хуже установившегося. Несколько тысяч прогонов на старте занимают доли
* секунды и переводят горячий путь на оптимизирующий компилятор до того, как придут
* настоящие запросы.
*
* <p>Прогрев идёт через отдельный экземпляр обработки со своим короткоживущим
* хранилищем: настоящие соответствия «текст ↔ маска» замусорить нельзя.
*
* <p>Вторая ступень при прогреве выключена, и не только ради времени: её счётчики
* показывают долю запросов, дошедших до модели, а тысячи служебных прогонов эту
* долю исказили бы до неузнаваемости. Сама модель прогревается отдельно, при
* создании своего пула.
*/
@Component
public class PipelineWarmup {
private static final Logger LOG = LoggerFactory.getLogger(PipelineWarmup.class);
/** Тексты подобраны так, чтобы задеть основные семейства правил. */
private static final String[] SAMPLES = {
"Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67",
"Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru",
"Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111",
"Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ",
"Напиши краткое описание продукта для рассылки клиентам банка",
};
private final RuleRegistry registry;
private final Masker masker;
private final int iterations;
public PipelineWarmup(RuleRegistry registry, Masker masker,
@Value("${pdguard.warmup-iterations:2000}") int iterations) {
this.registry = registry;
this.masker = masker;
this.iterations = iterations;
}
@PostConstruct
void warmup() {
if (iterations <= 0) {
LOG.info("Прогрев обработки отключён");
return;
}
long started = System.nanoTime();
Pipeline scratch = new Pipeline(registry, masker, new PayloadStore(1_000_000L, 1),
NameCascade.disabled());
SystemPolicy policy = new SystemPolicy(SystemPolicy.DEFAULT_NAME, true, true, MaskMode.MASK,
Set.of(SystemPolicy.ALL), SystemPolicy.DEFAULT.requireCompanion(), null);
for (int i = 0; i < iterations; i++) {
String text = SAMPLES[i % SAMPLES.length];
String id = "warmup-" + i;
String masked = scratch.process(text, id, policy);
scratch.process(masked, id, policy);
}
LOG.info("Прогрев обработки: {} прогонов за {} мс",
iterations, (System.nanoTime() - started) / 1_000_000);
}
}
@@ -0,0 +1,167 @@
package ru.pdguard.core;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Общий слой соответствий «текст ↔ маска» для работы на нескольких узлах.
*
* <p>Маскирование — чистая функция, на любом узле даёт один и тот же результат.
* Обратное же преобразование требует состояния: если прямой запрос обработал
* один узел, а обратный попал на другой, соответствие должно быть общим.
*
* <p>Включается настройкой {@code pdguard.store.backend=redis}. Пока она не
* выставлена, к Redis не обращаются вовсе и зависимость остаётся неактивной.
*
* <p>Недоступность Redis не приводит к отказу: запись и чтение деградируют до
* локальной памяти узла, а ошибка попадает в журнал. Чтобы простой Redis не
* съедал время ответа, команды ограничены по времени настройкой
* {@code spring.data.redis.timeout}, а после нескольких подряд неудач общий слой
* временно перестают опрашивать вовсе.
*/
@Component
public class SharedIndex {
private static final Logger LOG = LoggerFactory.getLogger(SharedIndex.class);
private static final String KEY_BY_ID = "pdg:id:";
private static final String KEY_BY_MASK = "pdg:mask:";
/** Сколько подряд неудач размыкает предохранитель. */
private static final int FAILURES_TO_OPEN = 3;
/** На сколько общий слой перестают опрашивать после размыкания. */
private static final long OPEN_MILLIS = 5_000;
/** Пара «исходный текст — маска», как она хранится в общем слое. */
public record SharedEntry(String original, String masked) {
}
private final boolean enabled;
private final Duration ttl;
private final StringRedisTemplate redis;
private final ObjectMapper mapper;
private final AtomicInteger consecutiveFailures = new AtomicInteger();
private volatile long silentUntil;
private volatile boolean reported;
public SharedIndex(StringRedisTemplate redis,
@Value("${pdguard.store.backend:memory}") String backend,
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
ObjectMapper mapper) {
this.redis = redis;
this.enabled = "redis".equalsIgnoreCase(backend);
this.ttl = Duration.ofMinutes(ttlMinutes);
this.mapper = mapper;
}
/** Выключенный слой — для тестов и для сборки без Redis. */
public static SharedIndex disabled() {
return new SharedIndex(null, "memory", 30, new ObjectMapper());
}
public boolean enabled() {
return enabled;
}
public void put(String system, String payloadId, String original, String masked,
String maskFingerprint) {
if (unavailable()) {
return;
}
try {
redis.opsForValue().set(KEY_BY_ID + scoped(system, payloadId),
toJson(new SharedEntry(original, masked)), ttl);
redis.opsForValue().set(KEY_BY_MASK + scoped(system, maskFingerprint), original, ttl);
noteSuccess();
} catch (RuntimeException e) {
noteFailure("записать", e);
}
}
public SharedEntry byId(String system, String payloadId) {
if (unavailable()) {
return null;
}
try {
String json = redis.opsForValue().get(KEY_BY_ID + scoped(system, payloadId));
noteSuccess();
return json == null ? null : fromJson(json);
} catch (RuntimeException e) {
noteFailure("прочитать", e);
return null;
}
}
public String originalForFingerprint(String system, String maskFingerprint) {
if (unavailable()) {
return null;
}
try {
String original = redis.opsForValue().get(KEY_BY_MASK + scoped(system, maskFingerprint));
noteSuccess();
return original;
} catch (RuntimeException e) {
noteFailure("прочитать", e);
return null;
}
}
private String toJson(SharedEntry entry) {
try {
return mapper.writeValueAsString(entry);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Не удалось сериализовать соответствие", e);
}
}
private SharedEntry fromJson(String json) {
try {
return mapper.readValue(json, SharedEntry.class);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Не удалось разобрать соответствие из общего слоя", e);
}
}
/** Ключ с разделением по системам: длина имени в начале снимает вопрос о разделителе. */
private static String scoped(String system, String key) {
String owner = system == null ? "" : system;
return owner.length() + ":" + owner + ":" + key;
}
/** Общий слой выключен или предохранитель разомкнут. */
private boolean unavailable() {
return !enabled || System.currentTimeMillis() < silentUntil;
}
private void noteSuccess() {
if (consecutiveFailures.getAndSet(0) != 0) {
reported = false;
LOG.info("Общий слой снова доступен");
}
}
/**
* После нескольких неудач подряд общий слой перестают опрашивать на несколько
* секунд: иначе каждый запрос платил бы таймаутом за недоступный Redis, а
* проверяющая система считает ответ дольше десяти секунд неответом.
*/
private void noteFailure(String action, RuntimeException cause) {
if (consecutiveFailures.incrementAndGet() >= FAILURES_TO_OPEN) {
silentUntil = System.currentTimeMillis() + OPEN_MILLIS;
}
if (!reported) {
reported = true;
LOG.error("Не удалось {} соответствие в общий слой, узел работает на своей памяти", action, cause);
}
}
}
@@ -0,0 +1,53 @@
package ru.pdguard.detect;
import java.util.Locale;
/**
* Общий приём для словарей, сравнивающих слово из текста с основой из списка:
* личные имена ({@link NameDictionary}) и города ({@link ToponymDictionary}).
*
* <p>Слова на согласную склоняются добавлением окончания («Тамбов» → «Тамбове»,
* «Пушкин» → «Пушкина») — там основы из списка достаточно как есть. Слова на
* гласную меняют последнюю букву («Москва» → «Москве», «Ольга» → «Ольге») —
* для них сравнение идёт по основе без неё.
*
* <p>Фамилии на «-ский» склоняются как прилагательное: окончание меняется
* целиком («Дзержинский» → «Дзержинского», «-ий» на «-ого», а не дописывается),
* поэтому для них отсечения одной буквы недостаточно — основа обрезается сразу
* до «ск». Для улиц в честь людей это не редкий случай, а основной: «улица
* Дзержинского», «улица Островского» пишутся только в родительном падеже,
* именительный там не встречается вообще.
*/
final class Declension {
/**
* Падежные окончания прилагательного склонения на «-ск-»: мужской, женский
* и средний род, все падежи. Проверяются от длинных к коротким — «-ского»
* не должно потеряться из-за более короткого совпадения на «-ким» и т.п.
*/
private static final String[] ADJECTIVE_ENDINGS = {
"ского", "скому", "ским", "ском", "скую", "ской", "скою", "ская", "ский"
};
private Declension() {
}
/**
* Отбрасывает у основы окончание, которое меняется по падежам: гласную —
* у обычных слов, целиком «-ск-»-окончание — у прилагательных фамилий.
* Слова короче четырёх букв не трогает — короткая основа и так шире
* большинства падежных форм.
*/
static String withoutInflectedEnding(String word) {
String lower = word.toLowerCase(Locale.ROOT);
for (String ending : ADJECTIVE_ENDINGS) {
if (lower.length() > ending.length() && lower.endsWith(ending)) {
return lower.substring(0, lower.length() - ending.length() + 2);
}
}
if (lower.length() >= 4 && "аяйь".indexOf(lower.charAt(lower.length() - 1)) >= 0) {
return lower.substring(0, lower.length() - 1);
}
return lower;
}
}
@@ -0,0 +1,231 @@
package ru.pdguard.detect;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import ru.pdguard.detect.Span;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Вторая ступень распознавания.
*
* <p>Правила и словарь разбирают подавляющее большинство случаев и стоят десятки
* микросекунд. Модель нужна там, где они бессильны: имена без русского
* словообразования и нестандартные топонимы.
*
* <p>Поэтому модель зовут не на весь текст, а только на кандидатов — цепочки из
* двух-трёх слов с заглавной буквы, которые первая ступень не покрыла. Их в обычном
* запросе единицы, и на задержку это почти не влияет. Дороже модель — тем важнее
* такая экономия: у BERT вызов стоит десятки миллисекунд, и звать его на каждый
* запрос было бы невозможно.
*
* <p>Движок один — модель BERT, размечающая имена и составляющие адреса. Настройка
* {@code pdguard.ner.engine} принимает {@code off} или {@code rubert}; ступень
* выключена, пока движок не задан.
*
* <p>Ступень выключена, пока не задан движок. Сбой ступени на первую не влияет:
* ошибка перехватывается здесь, ступень выключается насовсем, и дальше работают
* правила. Иначе одно исключение обнуляло бы маскирование целиком.
*/
@Component
public class NameCascade {
private static final Logger LOG = LoggerFactory.getLogger(NameCascade.class);
/** Имя метрики обращений ко второй ступени, её описание и имя метки исхода. */
private static final String NER_REQUESTS_METRIC = "pdguard.ner.requests";
private static final String NER_REQUESTS_DESCRIPTION = "Обращения, дошедшие до второй ступени";
private static final String OUTCOME_TAG = "outcome";
/** Цепочка из двух-трёх слов с заглавной буквы — то, что может оказаться ПД. */
private static final Pattern CANDIDATE = Pattern.compile(
"\\p{Lu}[\\p{L}-]+(?:\\s+\\p{Lu}[\\p{L}-]+){1,2}",
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
/** Приоритет находок второй ступени: ниже правил, у которых больше оснований. */
private static final int PRIORITY = 73;
/** Сколько знаков текста вокруг кандидата отдаётся модели как контекст. */
private static final int CONTEXT_CHARS = 60;
private final RuBertRecogniser recogniser;
private final Semaphore concurrent;
private final int maxCandidates;
private volatile boolean broken;
/**
* Сколько обращений дошло до модели, а сколько обошлось правилами. Отношение
* {@code engaged} ко всем обращениям и есть та доля, от которой зависит,
* посильна ли тяжёлая модель на боевом трафике.
*/
private final Counter engaged;
private final Counter withoutCandidates;
private final Counter busy;
private final Counter candidates;
private final Timer duration;
@Autowired
public NameCascade(
@Value("${pdguard.ner.engine:off}") String engine,
@Value("${pdguard.ner.model:}") String modelPath,
@Value("${pdguard.ner.max-candidates:16}") int maxCandidates,
@Value("${pdguard.ner.pool-size:16}") int poolSize,
MeterRegistry meters) {
this.maxCandidates = maxCandidates;
this.recogniser = create(engine, Optional.ofNullable(modelPath).filter(p -> !p.isBlank()));
this.concurrent = new Semaphore(Math.max(1, poolSize));
this.engaged = Counter.builder(NER_REQUESTS_METRIC)
.description(NER_REQUESTS_DESCRIPTION)
.tag(OUTCOME_TAG, "engaged").register(meters);
this.withoutCandidates = Counter.builder(NER_REQUESTS_METRIC)
.description(NER_REQUESTS_DESCRIPTION)
.tag(OUTCOME_TAG, "no_candidates").register(meters);
this.busy = Counter.builder(NER_REQUESTS_METRIC)
.description(NER_REQUESTS_DESCRIPTION)
.tag(OUTCOME_TAG, "busy").register(meters);
this.candidates = Counter.builder("pdguard.ner.candidates")
.description("Участки текста, отданные модели").register(meters);
this.duration = Timer.builder("pdguard.ner.duration")
.description("Время работы второй ступени").register(meters);
}
/** Конструктор для тестов: метрики никуда не отдаются. */
public NameCascade(String engine, Optional<String> modelPath, int maxCandidates, int poolSize) {
this(engine, modelPath.orElse(""), maxCandidates, poolSize, new SimpleMeterRegistry());
}
/**
* Выключенная ступень для служебных нужд — прогрева и тестов. Отдельный
* конструктор, а не обычный путь: иначе в журнале рядом с сообщением о готовности
* распознавателя появлялось бы сообщение о его выключении, и было бы непонятно,
* что в итоге работает.
*/
private NameCascade() {
this.maxCandidates = 0;
this.recogniser = null;
this.concurrent = new Semaphore(1);
MeterRegistry meters = new SimpleMeterRegistry();
this.engaged = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "engaged");
this.withoutCandidates = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "no_candidates");
this.busy = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "busy");
this.candidates = meters.counter("pdguard.ner.candidates");
this.duration = Timer.builder("pdguard.ner.duration").register(meters);
}
public static NameCascade disabled() {
return new NameCascade();
}
public boolean enabled() {
return recogniser != null && !broken;
}
/**
* Добавляет ПД, которые не нашла первая ступень. Уже принятые фрагменты не
* трогаются: модель разбирает только непокрытые участки.
*/
public List<Span> addMissedNames(String text, List<Span> accepted) {
if (!enabled()) {
return accepted;
}
if (!concurrent.tryAcquire()) {
// Модель занята целиком: отвечаем по правилам, а не копим очередь.
busy.increment();
return accepted;
}
long started = System.nanoTime();
try {
List<Span> found = new ArrayList<>(accepted);
int examined = 0;
Matcher m = CANDIDATE.matcher(text);
while (m.find() && examined < maxCandidates) {
if (coveredBy(accepted, m.start(), m.end())) {
continue;
}
examined++;
collect(text, m.start(), m.end(), found);
}
candidates.increment(examined);
(examined > 0 ? engaged : withoutCandidates).increment();
duration.record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
return found;
} catch (RuntimeException e) {
broken = true;
LOG.error("Вторая ступень отключена из-за сбоя, распознавание продолжается по правилам", e);
return accepted;
} finally {
concurrent.release();
}
}
private void collect(String text, int candidateStart, int candidateEnd, List<Span> sink) {
int from = Math.max(0, candidateStart - CONTEXT_CHARS);
int to = Math.min(text.length(), candidateEnd + CONTEXT_CHARS);
for (Span span : recogniser.recognise(text, from, to, PRIORITY)) {
if (isAccepted(text, candidateStart, candidateEnd, span)) {
sink.add(span);
}
}
}
/**
* Принимает находку модели, если она пересекается с кандидатом и проходит
* те же условия, что и находки правил.
*/
private static boolean isAccepted(String text, int candidateStart, int candidateEnd, Span span) {
// Берём только пересекающееся с кандидатом: контекст добавлен ради
// качества разбора, а не для расширения находки.
if (span.start() >= candidateEnd || candidateStart >= span.end()) {
return false;
}
// Адресные типы принимаются на тех же условиях, что и от правил: рядом
// должны быть другие части адреса. Иначе «Спартак Москва» и «Проспект
// Вернадского» попадали бы под маску наравне с адресом клиента.
return !RuleRegistry.isAddressType(span.type())
|| RuleRegistry.hasAddressContext(text, span.start(), span.end());
}
private static boolean coveredBy(List<Span> accepted, int start, int end) {
return accepted.stream().anyMatch(span -> span.start() < end && start < span.end());
}
private static RuBertRecogniser create(String engine, Optional<String> modelPath) {
String chosen = engine == null ? "off" : engine.toLowerCase(Locale.ROOT).strip();
if ("off".equals(chosen) || modelPath.isEmpty() || modelPath.get().isBlank()) {
LOG.info("Вторая ступень распознавания выключена");
return null;
}
if (!"rubert".equals(chosen)) {
LOG.warn("Неизвестный движок второй ступени: {}, ступень выключена", chosen);
return null;
}
RuBertRecogniser created = RuBertRecogniser.load(Path.of(modelPath.get()), 1);
if (created == null) {
LOG.info("Вторая ступень распознавания выключена: распознаватель не создан");
}
return created;
}
@PreDestroy
void shutdown() {
if (recogniser != null) {
recogniser.close();
}
}
}
@@ -0,0 +1,272 @@
package ru.pdguard.detect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* Словари для распознавания ФИО.
*
* <p>Личные имена нужны, чтобы морфология фамилий не срабатывала на чём попало:
* «Тверская» по окончанию похожа на фамилию, но рядом с ней нет личного имени.
*
* <p>Список известных людей решает обратную задачу — упоминание Пушкина
* персональными данными не является. Ограничение осознанное: клиент по фамилии
* Пушкин в тексте без других ПД замаскирован не будет.
*
* <p>Базовый список собран в сборку из {@code /names/well-known.txt}. Поверх
* него можно дописать своих публичных лиц без пересборки — файл по пути
* {@code pdguard.well-known-file} (по умолчанию {@code config/well-known.txt})
* перечитывается сам при изменении, тем же приёмом, что {@code systems.json}
* в {@link ru.pdguard.config.SystemsConfig}: раз в секунду сверяется время
* изменения, содержимое читается заново только когда оно другое.
*/
public final class NameDictionary {
private static final Logger LOG = LoggerFactory.getLogger(NameDictionary.class);
private static final long RECHECK_MILLIS = 1000;
private static final List<String> GIVEN_NAME_STEMS = load("/names/given-names.txt").stream()
.map(Declension::withoutInflectedEnding)
.distinct()
.sorted(Comparator.comparingInt(String::length).reversed())
.toList();
// Гласная в конце основы отбрасывается: «Набиуллина» родительный/дательный/
// творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не
// дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём,
// что и для личных имён.
private static final Set<String> BUNDLED_WELL_KNOWN_STEMS = load("/names/well-known.txt").stream()
.map(Declension::withoutInflectedEnding)
.collect(Collectors.toUnmodifiableSet());
private static final AtomicReference<Path> externalFile =
new AtomicReference<>(Path.of("config/well-known.txt"));
private static final AtomicReference<Set<String>> wellKnownStems =
new AtomicReference<>(BUNDLED_WELL_KNOWN_STEMS);
private static volatile long externalTimestamp;
private static volatile long lastCheck;
/**
* Маркер организации вплотную перед именем. Слово может стоять в любом падеже,
* между ним и именем допускается «имени» или «им.» — «Премия имени Ломоносова».
*/
private static final Pattern ORGANISATION_BEFORE = Pattern.compile(
"(?iu:" + String.join("|", load("/names/organisations.txt")) + ")\\p{L}*"
+ "(?:\\W{1,3}(?iu:имени|им\\.))?\\W{0,3}$",
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
/** Сколько знаков перед именем просматривается в поисках маркера организации. */
private static final int ORGANISATION_LOOKBEHIND = 40;
/** Порядковые числительные в имени правителя: «Пётр Первый», «Екатерина Вторая». */
private static final String REGNAL_ORDINALS =
"перв|втор|трет|четв[её]рт|пят|шест|седьм|восьм|девят|десят";
/** Прозвища правителей: «Иван Грозный», «Ярослав Мудрый», «Александр Освободитель». */
private static final String REGNAL_EPITHETS =
"велик|грозн|мудр|благословен|освободител|миротворц?|тишайш|долгорук|окаянн";
/**
* Имя правителя: личное имя плюс порядковое числительное или прозвище —
* «Пётр Первый», «Иван Грозный», «Екатерина Вторая», «Ярослав Мудрый».
* Задано правилом, а не перечнем: правителей много, а форма записи одна.
*/
private static final Pattern REGNAL_NAME = Pattern.compile(
"^\\p{Lu}\\p{L}+\\s+(?iu:" + REGNAL_ORDINALS + "|" + REGNAL_EPITHETS + ")\\p{L}*$",
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE | Pattern.CANON_EQ);
/** Не более скольких падежных букв дописывается к основе имени. */
private static final int MAX_INFLECTION = 3;
/** Остатки, превращающие основу имени в фамилию или отчество: Роман → Романов. */
private static final Set<String> SURNAME_SUFFIXES = Set.of(
"ов", "ев", "ёв", "ин", "ын", "ова", "ева", "ёва", "ина", "ына",
"ович", "евич", "овна", "евна", "овы", "евы", "ины");
private static final Set<String> GIVEN_NAMES = GIVEN_NAME_STEMS.stream()
.map(stem -> stem.toLowerCase(Locale.ROOT))
.collect(Collectors.toUnmodifiableSet());
private NameDictionary() {
}
/** Экземпляр для Spring-бина; словарь работает через статические методы. */
public static NameDictionary create() {
return new NameDictionary();
}
/**
* Задаёт путь к внешнему файлу денилиста. Вызывается при старте приложения
* из конфигурации Spring-бина; статические методы словаря работают без
* экземпляра, поэтому путь хранится в статическом поле.
*/
public static void configure(String wellKnownFile) {
externalFile.set(Path.of(wellKnownFile));
}
/**
* Есть ли среди слов личное имя из словаря в любом падеже.
*
* <p>Проверка множеством, а не чередованием в регулярном выражении: сто с лишним
* веток пришлось бы перебирать в каждой позиции текста, здесь же на слово
* приходится не больше четырёх обращений к хеш-таблице.
*/
public static boolean containsGivenName(String value) {
for (String word : value.split("\\P{L}+")) {
String lower = word.toLowerCase(Locale.ROOT);
// Точное совпадение с основой сильнее всего: «Яков» оканчивается на «ов»,
// но это имя, а не фамилия.
if (GIVEN_NAMES.contains(lower)) {
return true;
}
// По началу слова имя ищется с оглядкой на остаток: «Марина» это основа
// «марин» плюс падежное «а», а «Романов» — основа «роман» плюс фамильное
// «ов». Без этой разницы «Бизнес-центр Романов Двор» принимался бы за
// человека, а «Марина Шевченко» переставала бы им быть.
for (int length = Math.max(1, lower.length() - MAX_INFLECTION); length < lower.length(); length++) {
if (GIVEN_NAMES.contains(lower.substring(0, length))
&& !SURNAME_SUFFIXES.contains(lower.substring(length))) {
return true;
}
}
}
return false;
}
/**
* Стоит ли перед именем слово, относящее его к организации или объекту на карте.
*
* <p>«Институт Склифосовского», «Музей Тропинина», «улица Королёва» — это имена
* в названиях, а не персональные данные. Отличие от списка известных людей в том,
* что здесь решает не само имя, а слово перед ним: клиент по фамилии Королёв
* защиту не теряет, а улица Королёва под маску не попадает.
*/
public static boolean precededByOrganisation(String text, int nameStart) {
int from = Math.max(0, nameStart - ORGANISATION_LOOKBEHIND);
return ORGANISATION_BEFORE.matcher(text.substring(from, nameStart)).find();
}
/**
* Содержит ли текст упоминание известного человека — из сборки или дописанных
* сверху.
*
* <p>Проверяются префиксы слова по множеству, а не каждая основа по слову:
* при тысяче с лишним записей (столько городов в {@link ToponymDictionary},
* тот же приём) перебор списка на каждое слово текста был бы заметен, а
* префиксов у слова — не больше, чем в нём букв.
*/
public static boolean isWellKnown(String value) {
if (REGNAL_NAME.matcher(value.strip()).matches()) {
return true;
}
refreshIfChanged();
Set<String> stems = wellKnownStems.get();
for (String word : value.split("\\P{L}+")) {
String lower = word.toLowerCase(Locale.ROOT);
for (int length = lower.length(); length > 0; length--) {
if (stems.contains(lower.substring(0, length))) {
return true;
}
}
}
return false;
}
/** Путь к внешнему файлу денилиста — для тестов, чтобы не трогать {@code config/}. */
static void useExternalFile(Path path) {
externalFile.set(path);
externalTimestamp = -1;
lastCheck = 0;
}
/** Перечитать внешний файл немедленно, минуя секундный троттлинг проверки. */
static synchronized void reloadExternal() {
lastCheck = System.currentTimeMillis();
if (!Files.isReadable(externalFile.get())) {
if (wellKnownStems.get() != BUNDLED_WELL_KNOWN_STEMS) {
LOG.info("Внешний файл денилиста {} исчез, остаётся только встроенный список",
externalFile.get().toAbsolutePath());
}
wellKnownStems.set(BUNDLED_WELL_KNOWN_STEMS);
externalTimestamp = 0;
return;
}
try {
externalTimestamp = Files.getLastModifiedTime(externalFile.get()).toMillis();
Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
for (String line : Files.readAllLines(externalFile.get(), StandardCharsets.UTF_8)) {
String trimmed = Declension.withoutInflectedEnding(line.trim());
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
merged.add(trimmed);
}
}
wellKnownStems.set(Set.copyOf(merged));
LOG.info("Денилист дополнен из {}: {} имён сверх встроенных",
externalFile.get().toAbsolutePath(), merged.size() - BUNDLED_WELL_KNOWN_STEMS.size());
} catch (IOException e) {
// Битый файл не должен ронять маскирование: остаётся прежний список.
LOG.error("Не удалось прочитать {}, денилист не изменён", externalFile.get().toAbsolutePath(), e);
}
}
private static void refreshIfChanged() {
long now = System.currentTimeMillis();
if (now - lastCheck < RECHECK_MILLIS) {
return;
}
lastCheck = now;
try {
if (!Files.isReadable(externalFile.get())) {
if (externalTimestamp != 0) {
reloadExternal();
}
return;
}
if (Files.getLastModifiedTime(externalFile.get()).toMillis() != externalTimestamp) {
reloadExternal();
}
} catch (IOException e) {
LOG.debug("Не удалось проверить время изменения {}", externalFile.get(), e);
}
}
/**
* Основы сортируются от длинных к коротким: в чередовании регулярного
* выражения побеждает первая подошедшая ветка, и короткая основа не должна
* перехватывать совпадение у длинной.
*/
private static List<String> load(String resource) {
try (InputStream in = NameDictionary.class.getResourceAsStream(resource)) {
if (in == null) {
throw new IllegalStateException("Словарь не найден в сборке: " + resource);
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
return reader.lines()
.map(String::trim)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.distinct()
.sorted(Comparator.comparingInt(String::length).reversed())
.toList();
}
} catch (IOException e) {
throw new UncheckedIOException("Не удалось прочитать словарь " + resource, e);
}
}
}
@@ -0,0 +1,209 @@
package ru.pdguard.detect;
import ai.onnxruntime.OnnxTensor;
import ai.onnxruntime.OrtEnvironment;
import ai.onnxruntime.OrtException;
import ai.onnxruntime.OrtSession;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import ru.pdguard.detect.Span;
import java.io.IOException;
import java.nio.LongBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Распознаватель на BERT: размечает имена и составляющие адреса за один проход.
*
* <p>В отличие от правил, он опознаёт имена без русского словообразования и
* нестандартные топонимы. Метки модели ложатся почти один в один
* на типы из технического задания: имя, отчество, фамилия, страна, регион, район,
* город, улица, дом.
*
* <p>Модель тяжёлая — сто семьдесят мегабайт и около двенадцати миллисекунд на
* вызов, — поэтому её зовут только на участках, которые не разобрала первая
* ступень. Одновременных вызовов не больше, чем задано: иначе один запрос с
* десятком кандидатов занял бы все ядра.
*/
final class RuBertRecogniser {
private static final Logger LOG = LoggerFactory.getLogger(RuBertRecogniser.class);
/** Предел длины входа: участки короткие, до потолка модели в 512 далеко. */
private static final int MAX_PIECES = 190;
/** Метки модели в типы ПД сервиса. Имя, отчество и фамилия — один тип. */
private static final Map<String, String> TYPES = Map.of(
"FIRST_NAME", RuleRegistry.FIO,
"MIDDLE_NAME", RuleRegistry.FIO,
"LAST_NAME", RuleRegistry.FIO,
"COUNTRY", RuleRegistry.ADDRESS_COUNTRY,
"REGION", RuleRegistry.ADDRESS_REGION,
"DISTRICT", RuleRegistry.ADDRESS_DISTRICT,
"CITY", RuleRegistry.ADDRESS_CITY,
"STREET", RuleRegistry.ADDRESS_STREET,
"HOUSE", RuleRegistry.ADDRESS_HOUSE);
private final OrtEnvironment environment;
private final OrtSession session;
private final WordPiece tokenizer;
private final String[] labels;
private final Set<String> inputNames;
private RuBertRecogniser(OrtEnvironment environment, OrtSession session,
WordPiece tokenizer, String[] labels) throws OrtException {
this.environment = environment;
this.session = session;
this.tokenizer = tokenizer;
this.labels = labels;
this.inputNames = session.getInputNames();
}
/**
* Загружает модель из каталога с файлами {@code model_int8.onnx}, {@code vocab.txt}
* и {@code config.json}. Каталог недоступен или испорчен — вернётся {@code null},
* и сервис продолжит работать на правилах.
*/
static RuBertRecogniser load(Path directory, int threadsPerCall) {
Path model = directory.resolve("model_int8.onnx");
Path vocabulary = directory.resolve("vocab.txt");
Path config = directory.resolve("config.json");
if (!Files.isReadable(model) || !Files.isReadable(vocabulary) || !Files.isReadable(config)) {
LOG.warn("Модель BERT в {} неполна, распознаватель не создан", directory.toAbsolutePath());
return null;
}
try {
OrtEnvironment environment = OrtEnvironment.getEnvironment();
OrtSession.SessionOptions options = new OrtSession.SessionOptions();
options.setIntraOpNumThreads(threadsPerCall);
options.setInterOpNumThreads(1);
OrtSession session = environment.createSession(model.toString(), options);
RuBertRecogniser recogniser = new RuBertRecogniser(environment, session,
WordPiece.fromVocabulary(vocabulary), readLabels(config));
LOG.info("Распознаватель BERT готов, модель {}", model.toAbsolutePath());
return recogniser;
} catch (OrtException | IOException | RuntimeException e) {
LOG.error("Не удалось загрузить модель BERT из {}", directory.toAbsolutePath(), e);
return null;
}
}
List<Span> recognise(String text, int from, int to, int priority) {
String region = text.substring(from, to);
List<WordPiece.Piece> pieces = tokenizer.split(region, MAX_PIECES);
if (pieces.isEmpty()) {
return List.of();
}
try {
String[] tags = classify(pieces);
return toSpans(pieces, tags, from, priority);
} catch (OrtException e) {
throw new IllegalStateException("Сбой вычисления модели BERT", e);
}
}
private String[] classify(List<WordPiece.Piece> pieces) throws OrtException {
int length = pieces.size() + 2;
long[] ids = new long[length];
long[] mask = new long[length];
long[] types = new long[length];
ids[0] = tokenizer.classifyId();
for (int i = 0; i < pieces.size(); i++) {
ids[i + 1] = pieces.get(i).id();
}
ids[length - 1] = tokenizer.separatorId();
java.util.Arrays.fill(mask, 1L);
long[] shape = {1, length};
Map<String, OnnxTensor> inputs = new HashMap<>();
try {
inputs.put("input_ids", OnnxTensor.createTensor(environment, LongBuffer.wrap(ids), shape));
inputs.put("attention_mask", OnnxTensor.createTensor(environment, LongBuffer.wrap(mask), shape));
if (inputNames.contains("token_type_ids")) {
inputs.put("token_type_ids", OnnxTensor.createTensor(environment, LongBuffer.wrap(types), shape));
}
inputs.keySet().retainAll(inputNames);
try (OrtSession.Result result = session.run(inputs)) {
float[][][] logits = (float[][][]) result.get(0).getValue();
String[] tags = new String[pieces.size()];
for (int i = 0; i < pieces.size(); i++) {
tags[i] = labels[argmax(logits[0][i + 1])];
}
return tags;
}
} finally {
inputs.values().forEach(OnnxTensor::close);
}
}
/**
* Собирает подряд идущие подслова одной сущности в фрагменты исходного текста.
* Схема разметки различает начало, середину, конец и одиночный токен, но для
* сборки достаточно смены типа: границы участков и так проставлены по словам.
*/
private static List<Span> toSpans(List<WordPiece.Piece> pieces, String[] tags, int offset, int priority) {
List<Span> spans = new ArrayList<>();
String currentType = null;
int start = 0;
int end = 0;
for (int i = 0; i < tags.length; i++) {
String type = TYPES.get(entityOf(tags[i]));
if (type != null && type.equals(currentType)) {
end = pieces.get(i).end();
continue;
}
if (currentType != null) {
spans.add(new Span(offset + start, offset + end, currentType, priority));
}
currentType = type;
start = pieces.get(i).start();
end = pieces.get(i).end();
}
if (currentType != null) {
spans.add(new Span(offset + start, offset + end, currentType, priority));
}
return spans;
}
private static String entityOf(String tag) {
int dash = tag.indexOf('-');
return dash < 0 ? tag : tag.substring(dash + 1);
}
private static int argmax(float[] scores) {
int best = 0;
for (int i = 1; i < scores.length; i++) {
if (scores[i] > scores[best]) {
best = i;
}
}
return best;
}
private static String[] readLabels(Path config) throws IOException {
JsonNode node = new ObjectMapper().readTree(Files.readAllBytes(config)).get("id2label");
String[] labels = new String[node.size()];
for (Iterator<Map.Entry<String, JsonNode>> it = node.fields(); it.hasNext(); ) {
Map.Entry<String, JsonNode> entry = it.next();
labels[Integer.parseInt(entry.getKey())] = entry.getValue().asText();
}
return labels;
}
void close() {
try {
session.close();
} catch (OrtException e) {
LOG.debug("Не удалось закрыть сессию модели", e);
}
}
}
+99
View File
@@ -0,0 +1,99 @@
package ru.pdguard.detect;
import java.util.List;
import java.util.function.Predicate;
import java.util.regex.Pattern;
/**
* Одно правило детекции персональных данных.
*
* <p>Добавление нового типа ПД — это добавление одного {@code Rule} в
* {@link RuleRegistry}; менять остальной код не требуется.
*
* @param type тип ПД, который распознаёт правило
* @param pattern регулярное выражение
* @param priority приоритет при разрешении перекрытий
* @param groups номера групп, которые маскируются; {@code 0} — всё совпадение целиком.
* Несколько групп нужны, когда значение разорвано словами:
* «серия 4509 номер 123456»
* @param validator дополнительная проверка значения (контрольная сумма, диапазон дат);
* {@code null} — проверка не нужна
* @param veto шаблон окружения, при котором совпадение персональными данными не считается:
* адрес отделения банка не является ПД, хотя выглядит как адрес
* @param context шаблон окружения, который обязан присутствовать рядом. Нужен там,
* где форма совпадения сама по себе слишком общая: «Невский проспект»
* это адрес рядом с домом и индексом и просто топоним в рассказе о городе
* @param anchors строчные подстроки, одна из которых обязана встретиться в тексте.
* Проверка через {@code indexOf} на порядок дешевле запуска
* регулярного выражения и отсекает большинство правил на коротком
* запросе. Пустой список — правило запускается всегда
*/
public record Rule(String type, Pattern pattern, int priority, List<Integer> groups,
Predicate<String> validator, Pattern veto, Pattern context, List<String> anchors) {
/**
* Флаги компиляции для всех правил.
*
* <p>{@code UNICODE_CHARACTER_CLASS} обязателен: без него {@code \w}, {@code \W}
* и {@code \b} в Java охватывают только латиницу, и якорные слова вроде
* «водительское удостоверение» не находятся. {@code UNICODE_CASE} делает
* {@code (?i)} корректным для кириллицы.
*/
private static final int FLAGS = Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE;
/**
* Сколько символов слева и справа от совпадения просматривает вето-шаблон.
*
* <p>150, не 80: на реальных адресах отделений из реестра ЦБ (регион, город,
* улица, дом — в одном предложении) расстояние от «отделение» до номера дома
* часто превышает 80 знаков за счёт длинного названия региона («Ханты-Мансийский
* автономный округ», «Кабардино-Балкарская Республика»). Найдено нагрузочным
* тестом на 60 реальных адресах из официального реестра — с окном в 80 знаков
* вето не срабатывало на части из них.
*/
public static final int VETO_LOOKBEHIND = 150;
public static final int VETO_LOOKAHEAD = 40;
/** Правило без проверок, маскируется всё совпадение. */
public static Rule of(String type, String regex, int priority) {
return new Rule(type, Pattern.compile(regex, FLAGS), priority, List.of(0), null, null, null, List.of());
}
/** Маскировать только перечисленные группы, а не всё совпадение. */
public Rule groups(Integer... indexes) {
return new Rule(type, pattern, priority, List.of(indexes), validator, veto, context, anchors);
}
/** Принять совпадение, только если значение прошло проверку. */
public Rule validatedBy(Predicate<String> check) {
return new Rule(type, pattern, priority, groups, check, veto, context, anchors);
}
/** Запускать правило, только если в тексте есть одна из подстрок (в нижнем регистре). */
public Rule anchoredBy(String... required) {
return new Rule(type, pattern, priority, groups, validator, veto, context, List.of(required));
}
/** Есть ли в тексте хоть один из якорей правила. */
public boolean mayMatch(String lowercasedText) {
if (anchors.isEmpty()) {
return true;
}
for (String anchor : anchors) {
if (lowercasedText.contains(anchor)) {
return true;
}
}
return false;
}
/** Принять совпадение, только если рядом встретилось указанное слово. */
public Rule requiringNear(String regex) {
return new Rule(type, pattern, priority, groups, validator, veto, Pattern.compile(regex, FLAGS), anchors);
}
/** Отбросить совпадение, если рядом встретилось указанное слово. */
public Rule vetoedBy(String regex) {
return new Rule(type, pattern, priority, groups, validator, Pattern.compile(regex, FLAGS), context, anchors);
}
}
@@ -0,0 +1,522 @@
package ru.pdguard.detect;
import org.springframework.stereotype.Component;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.detect.Span;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Реестр правил детекции и сам поиск ПД в тексте.
*
* <p>Правила разбиты на три уровня доверия:
* <ol>
* <li>проверяемые контрольной суммой — карта, ИНН, СНИЛС: ложных срабатываний почти нет;</li>
* <li>однозначные по формату — email, телефон;</li>
* <li>требующие якорного слова — паспорт, водительское удостоверение, CVV, адрес и прочее,
* где сама по себе последовательность знаков ни о чём не говорит.</li>
* </ol>
*
* <p>Якорные слова распознаются без учёта регистра — флаг {@code (?iu:...)} навешен
* именно на них. На захватываемое значение регистронезависимость не распространяется:
* там, где значение опознаётся по заглавной букве, это существенно.
*/
@Component
public class RuleRegistry {
public static final String EMAIL = "EMAIL";
public static final String PHONE = "PHONE";
public static final String CARD = "CARD";
public static final String INN = "INN";
public static final String SNILS = "SNILS";
public static final String PASSPORT = "PASSPORT";
public static final String PASSPORT_ISSUER = "PASSPORT_ISSUER";
public static final String PASSPORT_DATE = "PASSPORT_DATE";
public static final String DEPT_CODE = "DEPT_CODE";
public static final String DRIVER_LICENSE = "DRIVER_LICENSE";
public static final String CITIZENSHIP = "CITIZENSHIP";
public static final String BIRTH_PLACE = "BIRTH_PLACE";
public static final String BIRTH_DATE = "BIRTH_DATE";
public static final String DATE = "DATE";
public static final String CVV = "CVV";
public static final String PIN = "PIN";
public static final String CARDHOLDER = "CARDHOLDER";
public static final String ADDRESS_COUNTRY = "ADDRESS_COUNTRY";
public static final String ADDRESS_POSTCODE = "ADDRESS_POSTCODE";
public static final String ADDRESS_CITY = "ADDRESS_CITY";
public static final String ADDRESS_STREET = "ADDRESS_STREET";
public static final String ADDRESS_HOUSE = "ADDRESS_HOUSE";
public static final String ADDRESS_FLAT = "ADDRESS_FLAT";
/** Регион и район размечает только модель второй ступени: правил под них нет. */
public static final String ADDRESS_REGION = "ADDRESS_REGION";
public static final String ADDRESS_DISTRICT = "ADDRESS_DISTRICT";
public static final String FIO = "FIO";
public static final String FOREIGN_PASSPORT = "FOREIGN_PASSPORT";
public static final String MILITARY_ID = "MILITARY_ID";
public static final String BIRTH_CERTIFICATE = "BIRTH_CERTIFICATE";
public static final String MEDICAL_POLICY = "MEDICAL_POLICY";
/** Банковские реквизиты сверх платёжной карты. */
public static final String ACCOUNT_NUMBER = "ACCOUNT_NUMBER";
public static final String BIK = "BIK";
public static final String CARD_EXPIRY = "CARD_EXPIRY";
public static final String INCOME = "INCOME";
public static final String OGRN = "OGRN";
public static final String OGRNIP = "OGRNIP";
public static final String KPP = "KPP";
public static final String BIOMETRIC = "BIOMETRIC";
/**
* Слово с заглавной буквы; остальные буквы любого регистра, чтобы
* «ИВАНОВ» распознавался наравне с «Иванов».
*/
private static final String CAPITALISED = "\\p{Lu}[\\p{Lu}\\p{Ll}]+";
/**
* Название улицы: от одного до трёх слов с заглавной буквы либо чисел —
* «Тверская», «Малая Никитская», «8 Марта». Ограничение по форме обязательно:
* без него правило дожёвывало строку до конца, и «Проспект Вернадского перекрыт
* до вечера» оказывался под маской целиком.
*/
private static final String STREET_NAME =
"(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)(?:\\s+(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)){0,2}";
/**
* Фамилия по словообразованию: Иванов, Ковалёва, Троицкий, Шевченко, Мкртчян.
* Хвост из двух букв покрывает падежные окончания: Ковалёв-ой, Иванов-а.
*/
private static final String SURNAME =
"\\p{Lu}[\\p{Lu}\\p{Ll}]*(?iu:ов|ев|ёв|ин|ын|ск(?:ий|ая|ого|ой|ом)|цк(?:ий|ая)"
+ "|енко|ко|ук|юк|ян|швили|дзе)\\p{L}{0,2}";
/**
* Отчество: признак надёжный, ни одно другое слово так не оканчивается.
* Основы даны без падежного окончания — Иванович, Ивановича, Ивановне.
*/
private static final String PATRONYMIC =
"\\p{Lu}[\\p{Lu}\\p{Ll}]+(?iu:ович|евич|ьич|мич|нич|тич|лич|кич|бич|сич"
+ "|овн|евн|иничн|ичн)\\p{L}{0,2}";
/** Серия и номер: «4509 123456», «45 09 123456», «4509123456», «45 09 № 123456». */
private static final String SERIES_AND_NUMBER = "\\d{2}\\s?\\d{2}[\\s№N]{0,3}\\d{6}";
private static final String MONTH =
"(?iu:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\\p{L}*";
/** Числовая запись при любом порядке частей: дд.мм.гггг, мм/дд/гггг, гггг-мм-дд. */
private static final String DATE_DIGITS = "\\b\\d{1,4}[.\\-/]\\d{1,2}[.\\-/]\\d{1,4}\\b";
/** «12 мая 1985 г.» */
private static final String DATE_MONTH_WORD =
"\\b\\d{1,2}\\s+" + MONTH + "\\s+\\d{4}\\b(?:\\s*(?iu:года|г\\.|г\\b))?";
/** «двенадцатого мая тысяча девятьсот восемьдесят пятого года» */
private static final String DATE_WORDS =
"\\b(?:(?iu:двадцать|тридцать)\\s+)?"
+ "(?iu:перв|втор|треть|четв[её]рт|пят|шест|седьм|восьм|девят|десят|одиннадцат|двенадцат"
+ "|тринадцат|четырнадцат|пятнадцат|шестнадцат|семнадцат|восемнадцат|девятнадцат|двадцат|тридцат)"
+ "(?iu:ьего|ого|его)\\s+" + MONTH
+ "\\s+(?:\\d{4}|(?iu:тысяча)[\\p{L}\\s]{5,60}?)\\s*(?iu:года|год\\b|г\\.)";
/** Любая из трёх записей даты; внутри только незахватывающие группы. */
private static final String DATE_ANY = "(?:" + DATE_WORDS + "|" + DATE_MONTH_WORD + "|" + DATE_DIGITS + ")";
/**
* Слова, при которых адрес принадлежит организации, а не человеку:
* адрес отделения банка персональными данными не является. Части адреса рядом:
* улица, упомянутая в рассказе о городе, адресом клиента не является — ровно
* как адрес отделения банка из технического задания. Требование стояло только
* у постфиксной формы правила, префиксная его не имела.
*/
public static final String ADDRESS_NEARBY =
"(?iu:адрес|индекс|\\bд\\.|\\bдом\\b|\\bкв\\.|\\bг\\.|\\bгород|регистрац|прожива)";
private static final Pattern ADDRESS_CONTEXT =
Pattern.compile(ADDRESS_NEARBY, Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
/** Адресные типы, которые вне адресного окружения персональными данными не являются. */
private static final java.util.Set<String> ADDRESS_TYPES = java.util.Set.of(
ADDRESS_COUNTRY, ADDRESS_REGION, ADDRESS_DISTRICT, ADDRESS_CITY,
ADDRESS_STREET, ADDRESS_HOUSE, ADDRESS_FLAT, ADDRESS_POSTCODE);
public static boolean isAddressType(String type) {
return ADDRESS_TYPES.contains(type);
}
/**
* Есть ли рядом другие части адреса. Правила проверяют это сами, а находкам
* второй ступени проверку нужно навязать снаружи: модель размечает «Москву» в
* названии клуба и «Вернадского» в названии проспекта наравне с настоящим адресом.
*/
public static boolean hasAddressContext(String text, int start, int end) {
return ADDRESS_CONTEXT.matcher(surroundings(text, start, end)).find();
}
private static final String ORGANISATION_NEARBY =
"(?iu:отделени|филиал|банкомат|доп\\.?\\s?офис|офис|головн|юридическ\\p{L}*\\s+адрес)";
private static final List<Rule> RULES = List.of(
// --- Уровень 3: значение опознаётся только рядом с якорным словом ---
Rule.of(CVV, "(?iu:\\b(?:cvv2?|cvc2?|код\\s+проверки|защитный\\s+код))\\W{0,5}(\\d{3,4})\\b", 92)
.groups(1)
.anchoredBy("cvv", "cvc", "код проверки", "защитный код"),
Rule.of(PIN, "(?iu:\\bпин[\\s-]?кода?|\\bpin[\\s-]?code|\\bpin)\\b\\W{0,5}(\\d{4,6})\\b", 92)
.groups(1)
.anchoredBy("пин", "pin"),
// «паспорт 4509 123456», «паспорт гражданина РФ 45 09 123456»
Rule.of(PASSPORT, "(?iu:паспорт)\\w*(?:\\W+(?iu:гражданина\\s+РФ|РФ|России|Российской\\s+Федерации))?"
+ "\\W{0,10}(" + SERIES_AND_NUMBER + ")\\b", 90)
.groups(1)
.anchoredBy("паспорт"),
// «серия 4509 номер 123456», «серии 45 09 № 123456»
// Между серией и номером помещается слово: «серия 4509 номер 123456»,
// «серии 4509 за номером 123456», «серия 4509 № 123456».
Rule.of(PASSPORT, "(?iu:сери)\\w{0,3}\\W{0,5}(\\d{2}\\s?\\d{2})[^\\d]{0,20}(\\d{6})\\b", 90)
.groups(1, 2)
.anchoredBy("сери"),
Rule.of(DRIVER_LICENSE, "(?iu:водительск\\w+\\s+удостоверени\\w+|в/у|вод\\.\\s?удост\\w*|\\bВУ)\\b"
+ "\\W{0,15}(" + SERIES_AND_NUMBER + ")\\b", 89)
.groups(1)
.anchoredBy("водительск", "в/у", "вод.", "ву "),
// --- Прочие документы, удостоверяющие личность ---
Rule.of(FOREIGN_PASSPORT, "(?iu:загранпаспорт|заграничн\\p{L}*\\s+паспорт)\\p{L}*"
+ "\\W{0,10}(\\d{2}\\s?\\d{7})\\b", 89)
.groups(1)
.anchoredBy("загранпаспорт", "заграничн"),
Rule.of(MILITARY_ID, "(?iu:военн\\p{L}*\\s+билет)\\p{L}*"
+ "\\W{0,10}(\\p{Lu}{2}\\s?\\d{7})\\b", 89)
.groups(1)
.anchoredBy("военн"),
Rule.of(BIRTH_CERTIFICATE, "(?iu:свидетельств\\p{L}*\\s+о\\s+рождении)"
+ "\\W{0,15}([IVXLC]{1,4}[- ]?\\p{Lu}{2}\\s?(?:№\\s?)?\\d{6})\\b", 89)
.groups(1)
.anchoredBy("свидетельств"),
Rule.of(MEDICAL_POLICY, "(?iu:полис\\p{L}*(?:\\s+ОМС)?)\\W{0,10}(\\d{16})\\b", 89)
.groups(1)
.anchoredBy("полис"),
Rule.of(DEPT_CODE, "(?iu:код\\w*\\s+подразделения|к/п)\\W{0,5}(\\d{3}\\s?-?\\s?\\d{3})\\b", 88)
.groups(1)
.anchoredBy("подразделени", "к/п"),
// --- Банковские реквизиты сверх карты ---
// Расчётный счёт — ровно 20 цифр после якоря, группировка пробелами не важна.
Rule.of(ACCOUNT_NUMBER, "(?iu:р/с|расчетн\\w*\\s+счет|расчётн\\w*\\s+счёт|лицев\\w*\\s+счет|"
+ "лицев\\w*\\s+счёт)\\W{0,5}((?:\\d[ ]?){19}\\d)\\b", 83)
.groups(1)
.anchoredBy("р/с", "расчетн", "расчётн", "лицев"),
Rule.of(BIK, "(?iu:бик)\\W{0,5}(\\d{9})\\b", 83)
.groups(1)
.anchoredBy("бик"),
// «действительна до 09/27», «exp 09/27» — срок действия карты, не дата рождения.
Rule.of(CARD_EXPIRY, "(?iu:срок\\s+действия|действительна?\\s+до|\\bexp\\w*)\\W{0,5}"
+ "(\\d{2}\\s?/\\s?\\d{2})\\b", 83)
.groups(1)
.anchoredBy("срок действия", "действительн", "exp"),
// ОГРНИП раньше ОГРН: без отрицательного просмотра «ОГРНИП» частично ловился бы
// ещё и правилом ОГРН. Контрольная сумма отсекает случайные 13/15-значные
// числа рядом со словом — раньше якоря было достаточно самого по себе.
Rule.of(OGRNIP, "(?iu:огрнип)\\W{0,5}(\\d{15})\\b", 83)
.groups(1)
.validatedBy(Validators::ogrnip)
.anchoredBy("огрнип"),
Rule.of(OGRN, "(?iu:огрн(?!ип))\\W{0,5}(\\d{13})\\b", 83)
.groups(1)
.validatedBy(Validators::ogrn)
.anchoredBy("огрн"),
Rule.of(KPP, "(?iu:кпп)\\W{0,5}(\\d{9})\\b", 83)
.groups(1)
.anchoredBy("кпп"),
// Доход/зарплата: сумма с разделителями тысяч. Между якорем и суммой может
// стоять слово («доход клиента», «доход за год») — без этого якорь ловил
// бы только «доход 85000», вплотную.
Rule.of(INCOME, "(?iu:доход|заработн\\w*\\s+плат\\w*|зарплат\\w*)(?:\\s+\\p{L}+){0,3}?"
+ "\\W{0,5}(\\d{1,3}(?:[\\s.]?\\d{3})*(?:,\\d{2})?)\\s?(?iu:руб\\p{L}*|₽)?\\b", 76)
.groups(1)
.anchoredBy("доход", "заработн", "зарплат"),
// Биометрия — сама фраза уже говорит, что дальше персональные данные, отдельного
// значения для захвата нет: маскируется якорная фраза целиком.
Rule.of(BIOMETRIC, "(?iu:биометрическ\\w*\\s+(?:данны\\w*|образц\\w*|шаблон\\w*)"
+ "|слепок\\s+голоса|отпечаток\\s+пальца|скан\\s+лица|\\bЕБС\\b)", 81)
.anchoredBy("биометри", "слепок голоса", "отпечаток пальца", "скан лица", "ебс"),
// --- Даты с явным якорем ---
Rule.of(BIRTH_DATE, "(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)\\W{0,5}(" + DATE_ANY + ")", 87)
.groups(1)
.validatedBy(Validators::date)
.anchoredBy("рожден"),
Rule.of(BIRTH_DATE, "(?iu:родил(?:ся|ась))\\W{0,5}(" + DATE_ANY + ")", 87)
.groups(1)
.validatedBy(Validators::date)
.anchoredBy("родил"),
Rule.of(BIRTH_DATE, "(" + DATE_ANY + ")\\s*(?iu:г\\.\\s?р\\.|г/р|года\\s+рождения)", 87)
.groups(1)
.validatedBy(Validators::date)
.anchoredBy("г.р", "г/р", "года рождения"),
// «дата выдачи 12.05.2015» и «дата выдачи паспорта 12.05.2015»
Rule.of(PASSPORT_DATE, "(?iu:дат\\p{L}*\\s+выдачи)(?:\\s+\\p{L}+)?\\W{0,5}(" + DATE_ANY + ")", 87)
.groups(1)
.validatedBy(Validators::date)
.anchoredBy("выдач"),
Rule.of(CARDHOLDER, "(?iu:держател\\w*(?:\\s+карты)?|cardholder|на\\s+имя)"
+ "\\W{0,10}([A-Z]{2,20}\\s+[A-Z]{2,20})\\b", 86)
.groups(1)
.anchoredBy("держател", "cardholder", "на имя"),
// --- ФИО ---
// Фамилия Имя Отчество: первое слово опознаётся по словообразованию фамилии.
// Свободная тройка «любое слово с заглавной + имя + отчество» здесь
// сознательно не используется: она захватывает глагол в начале
// предложения («Пригласите Ивана Сергеевича») и заметно дороже по времени.
// Фамилии без привычного окончания — Ким, Цой — ловятся по ролевому слову.
Rule.of(FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 79),
// Имя Отчество Фамилия — второй распространённый порядок слов.
Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\s+" + SURNAME + "\\b", 79),
// Иванов И.И. и И.И. Иванов
Rule.of(FIO, "\\b" + SURNAME + "\\s+\\p{Lu}\\.\\s?\\p{Lu}\\.", 79),
Rule.of(FIO, "\\b\\p{Lu}\\.\\s?\\p{Lu}\\.\\s?" + SURNAME + "\\b", 79),
// Имя Отчество без фамилии
Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 77),
// «ФИО: иванов иван иванович» — явный якорь снимает требование к регистру
Rule.of(FIO, "(?iu:\\bФИО|\\bф\\.\\s?и\\.\\s?о\\.|\\bна\\s+имя)"
+ "(?:\\s+\\p{L}+)?\\W{0,5}(\\p{L}{2,}(?:\\s+\\p{L}{2,}){0,2})\\b", 77)
.groups(1)
.anchoredBy("фио", "ф.и.о", "на имя"),
// «клиент Иванов Иван», «плательщик Петрова»
Rule.of(FIO, "(?iu:\\bклиент|\\bзаказчик|\\bпациент|\\bсотрудник|\\bвладел|\\bплательщик"
+ "|\\bполучател|\\bабонент|\\bв\\s+лице|\\bпредставител|\\bпоручител"
+ "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител"
+ "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател)\\p{L}*"
+ "\\W{0,5}(\\p{Lu}\\p{Ll}+(?:\\s+\\p{Lu}\\p{Ll}+){0,2})\\b", 77)
.groups(1)
.anchoredBy("клиент", "заказчик", "пациент", "сотрудник", "владел", "плательщик",
"получател", "абонент", "в лице", "представител", "поручител", "заёмщик",
"заемщик", "заявител", "доверител", "вкладчик", "ответственн",
"контактное лицо", "исполнител", "держател"),
// Фамилия рядом с личным именем из словаря: без словаря правило ловило бы
// «Тверская улица» и тому подобное. Имя проверяется по множеству уже
// после совпадения — чередование из ста веток в шаблоне обходится дорого.
// Самое слабое основание среди правил ФИО — ни ролевого слова, ни явного
// якоря, — поэтому именно здесь нужно вето на адресный контекст: «Великие
// Луки» (реальный город) распознаётся как имя «Лука» в падеже плюс
// случайное слово, «Богдана Хмельницкого» — улица в честь исторической
// фигуры. Найдено на реальных адресах отделений из реестра ЦБ.
Rule.of(FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\b", 74)
.validatedBy(NameDictionary::containsGivenName)
.vetoedBy(ORGANISATION_NEARBY),
Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + SURNAME + "\\b", 74)
.validatedBy(NameDictionary::containsGivenName)
.vetoedBy(ORGANISATION_NEARBY),
// --- Уровень 1: подтверждается контрольной суммой ---
Rule.of(CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85)
.validatedBy(Validators::luhn),
Rule.of(INN, "(?iu)\\bИНН\\b\\D{0,10}(\\d{12}|\\d{10})\\b", 84)
.groups(1)
.anchoredBy("инн"),
Rule.of(SNILS, "(?iu)(?:\\bСНИЛС\\b\\D{0,10})?(\\d{3}[ -]\\d{3}[ -]\\d{3}[ -]\\d{2})\\b", 84)
.groups(1)
.validatedBy(Validators::snils),
// --- Уровень 2: формат однозначен сам по себе ---
Rule.of(PHONE, "(?:\\+7|\\b8)[ ()-]{0,3}\\d{3}[ ()-]{0,3}\\d{3}[ -]{0,2}\\d{2}[ -]{0,2}\\d{2}\\b", 82),
Rule.of(EMAIL, "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b", 80)
.anchoredBy("@"),
// --- Уровень 3: свободный текст после якорного слова ---
// «выдан ОУФМС России по г. Москве 12.05.2015» — дата в состав органа не входит,
// её забирает отдельное правило. Приоритет выше городского, иначе от органа
// осталась бы замаскированной только его часть.
Rule.of(PASSPORT_ISSUER, "(?iu:выдан)[\\p{L}]*\\W{0,3}([^,;\\n]{3,90}?)"
+ "(?=\\s*\\d{1,2}[.\\-/]\\d{1,2}[.\\-/]\\d{2,4}|[,;\\n]|\\s*$)", 78)
.groups(1)
.anchoredBy("выдан"),
Rule.of(BIRTH_PLACE, "(?iu:мест\\w*\\s+рождения)\\W{0,5}([^,;\\n]{3,60}?)(?=\\s*[,;\\n]|\\s*$)", 76)
.groups(1)
.anchoredBy("рождения"),
Rule.of(BIRTH_PLACE, "(?iu:родил(?:ся|ась))[^,;\\n]{0,40}?\\s+в\\s+"
+ "([^,;\\n]{3,40}?)(?=\\s*[,;\\n]|\\s*$)", 76)
.groups(1)
.anchoredBy("родил"),
Rule.of(CITIZENSHIP, "(?iu:гражданств)\\w*\\W{0,5}"
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75)
.groups(1)
.anchoredBy("гражданств"),
Rule.of(CITIZENSHIP, "(?iu:граждан(?:ин|ка|ина|ки))\\b\\s+"
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75)
.groups(1)
.anchoredBy("граждан"),
// --- Адрес: каждая составляющая настраивается отдельно ---
Rule.of(ADDRESS_POSTCODE, "(?iu:индекс)\\W{0,5}(\\d{6})\\b", 74)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("индекс"),
Rule.of(ADDRESS_POSTCODE,
"\\b(\\d{6})(?=\\s*,?\\s*(?iu:г\\.|город|обл\\.|область|респ|край))", 74)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY),
// Не только «г.»: перепись, на которой проверяется словарь, покрывает
// сёла, посёлки, деревни, хутора и станицы — «рп. Ильинское», «с. Кукуево»
// из ТЗ без этих якорей не нашлись бы вообще, город там ни при чём.
Rule.of(ADDRESS_CITY, "(?iu:\\bг\\.|\\bгор\\.|\\bгород|\\bрп\\.|\\bпгт\\.?|\\bп\\.|\\bс\\.|\\bсело\\b"
+ "|\\bд\\.|\\bдеревня\\b|\\bдер\\.|\\bх\\.|\\bхутор\\b|\\bст-ца|\\bстаница|\\bаул\\b"
+ "|\\bсл\\.|\\bслобода\\b|\\bаал\\b)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b", 73)
.groups(1)
.validatedBy(ToponymDictionary::isKnownSettlement)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("г.", "гор", "город", "рп.", "пгт", "п.", "с.", "село", "д.", "деревня",
"дер.", "х.", "хутор", "ст-ца", "станица", "аул", "сл.", "слобода", "аал"),
Rule.of(ADDRESS_STREET,
"(?iu:\\bул\\.|\\bулиц\\p{L}*|\\bпр-т|\\bпроспект\\p{L}*|\\bпер\\.|\\bпереул\\p{L}*"
+ "|\\bш\\.|\\bшоссе|\\bб-р|\\bбульвар\\p{L}*|\\bнаб\\.|\\bнабережн\\p{L}*)"
+ "\\W{0,3}(" + STREET_NAME + ")", 73)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.requiringNear(ADDRESS_NEARBY)
.anchoredBy("ул", "просп", "пр-т", "пер.", "шоссе", "ш.", "бульвар", "б-р", "наб"),
// «Невский пр-т» — указатель после названия. Форма слишком общая, поэтому
// принимается только рядом с другими частями адреса: иначе под маску попал бы
// любой рассказ про Невский проспект.
Rule.of(ADDRESS_STREET, "\\b(\\p{Lu}[\\p{L}-]{2,30})\\s+"
+ "(?iu:пр-т|проспект|улиц\\p{L}*|шоссе|бульвар|переул\\p{L}*|набережн\\p{L}*)\\b", 73)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.requiringNear(ADDRESS_NEARBY)
.anchoredBy("пр-т", "проспект", "улиц", "шоссе", "бульвар", "переул", "набережн"),
Rule.of(ADDRESS_HOUSE,
"(?iu:\\bд\\.|\\bдом)\\s?(\\d+\\p{L}?(?:\\s?(?iu:к\\.|корп\\.?|стр\\.)\\s?\\d+)?)\\b", 72)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("д.", "дом"),
Rule.of(ADDRESS_FLAT, "(?iu:\\bкв\\.|\\bквартир\\p{L}*)\\s?(\\d+\\p{L}?)\\b", 72)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("кв"),
Rule.of(ADDRESS_COUNTRY,
"(?iu:стран\\p{L}*(?:\\s+(?:регистрации|проживания|гражданства))?)"
+ "\\W{0,5}(\\p{Lu}[\\p{L}-]{2,30})\\b", 71)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("стран"),
// --- Значения без якоря: принимаются только вместе с другими ПД ---
// ИНН физлица без якорного слова — только с верной контрольной суммой.
Rule.of(INN, "\\b\\d{12}\\b", 62)
.validatedBy(Validators::inn),
// Дата без якорного слова персональными данными сама по себе не является:
// маскируется, только если в тексте есть ПД другого типа.
Rule.of(DATE, DATE_ANY, 58)
.validatedBy(Validators::date)
);
/** Все типы ПД, которые умеет распознавать сервис. */
public List<String> knownTypes() {
return RULES.stream().map(Rule::type).distinct().toList();
}
/**
* Находит все фрагменты ПД, разрешённые политикой системы.
* Перекрытия здесь не разрешаются — это делает вызывающая сторона.
*/
public List<Span> detect(String text, SystemPolicy policy) {
List<Span> found = new ArrayList<>();
String lowercased = text.toLowerCase(Locale.ROOT);
for (Rule rule : RULES) {
if (!policy.allows(rule.type()) || !rule.mayMatch(lowercased)) {
continue;
}
collect(rule, text, found);
}
return found;
}
private static void collect(Rule rule, String text, List<Span> sink) {
Matcher m = rule.pattern().matcher(text);
while (m.find()) {
for (int group : rule.groups()) {
int start = m.start(group);
int end = m.end(group);
if (isValidGroup(rule, text, start, end)) {
sink.add(new Span(start, end, rule.type(), rule.priority()));
}
}
}
}
/** Проверяет, что фрагмент группы проходит все условия правила: границы, валидатор, veto и контекст. */
private static boolean isValidGroup(Rule rule, String text, int start, int end) {
if (start < 0 || end <= start) {
return false;
}
if (rule.validator() != null && !rule.validator().test(text.substring(start, end))) {
return false;
}
String surroundings = surroundings(text, start, end);
if (rule.veto() != null && rule.veto().matcher(surroundings).find()) {
return false;
}
return rule.context() == null || rule.context().matcher(surroundings).find();
}
static String surroundings(String text, int start, int end) {
int from = Math.max(0, start - Rule.VETO_LOOKBEHIND);
int to = Math.min(text.length(), end + Rule.VETO_LOOKAHEAD);
return text.substring(from, to);
}
}
+26
View File
@@ -0,0 +1,26 @@
package ru.pdguard.detect;
/**
* Найденный фрагмент персональных данных в исходном тексте.
*
* @param start индекс первого символа (включительно)
* @param end индекс за последним символом (исключительно)
* @param type тип ПД, например {@code CARD} или {@code EMAIL}
* @param priority приоритет при разрешении перекрытий: больше — важнее
*/
public record Span(int start, int end, String type, int priority) {
public Span {
if (start < 0 || end <= start) {
throw new IllegalArgumentException("Некорректные границы фрагмента: " + start + ".." + end);
}
}
public int length() {
return end - start;
}
public boolean overlaps(Span other) {
return start < other.end && other.start < end;
}
}
@@ -0,0 +1,77 @@
package ru.pdguard.detect;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Словарь населённых пунктов России — проверка того, что значение, пойманное
* правилом {@code ADDRESS_CITY}, действительно похоже на существующий город,
* село, посёлок или другой населённый пункт, а не на произвольное слово с
* заглавной буквы после якоря.
*
* <p>Не только официальные города (~1100 по классификатору): перепись
* добавляет сёла, деревни, хутора, станицы — «рп. Ильинское», «с. Кукуево»
* из ТЗ находятся ровно за счёт неё. Какой конкретно тип населённого пункта
* стоит перед названием, определяет якорь самого правила в {@link RuleRegistry},
* а не этот словарь — он только подтверждает, что название реальное.
*
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания
* («в Москве», «из Казани») тем самым покрываются без отдельного разбора
* морфологии, как и у известных людей в {@link NameDictionary}.
*/
public final class ToponymDictionary {
private static final Set<String> SETTLEMENT_STEMS = load("/names/settlements.txt").stream()
.map(Declension::withoutInflectedEnding)
.collect(Collectors.toUnmodifiableSet());
private ToponymDictionary() {
}
/**
* Похоже ли значение на название населённого пункта из словаря в любом
* падеже.
*
* <p>Названия на согласную склоняются добавлением окончания («Тамбов» →
* «Тамбове»), поэтому начало слова из словаря — уже достаточный признак.
* Названия на гласную меняют последнюю букву («Москва» → «Москве»), для
* них сравнение идёт по основе без неё — так же, как с личными именами
* в {@link NameDictionary}.
*
* <p>Проверяются префиксы значения по множеству, а не каждая из ~80 000
* основ по значению: перебор списка на каждое совпадение правила был бы
* на порядки дороже, чем нужно — префиксов у слова не больше, чем в нём букв.
*/
public static boolean isKnownSettlement(String value) {
String lower = value.strip().toLowerCase(Locale.ROOT);
for (int length = lower.length(); length > 0; length--) {
if (SETTLEMENT_STEMS.contains(lower.substring(0, length))) {
return true;
}
}
return false;
}
private static Set<String> load(String resource) {
try (InputStream in = ToponymDictionary.class.getResourceAsStream(resource)) {
if (in == null) {
throw new IllegalStateException("Словарь не найден в сборке: " + resource);
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
return reader.lines()
.map(String::trim)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.collect(Collectors.toUnmodifiableSet());
}
} catch (IOException e) {
throw new UncheckedIOException("Не удалось прочитать словарь " + resource, e);
}
}
}
@@ -0,0 +1,162 @@
package ru.pdguard.detect;
/**
* Проверки контрольных сумм. Отсекают случайные числовые последовательности,
* которые по форме похожи на ПД, но ими не являются.
*/
public final class Validators {
private static final int[] INN_10 = {2, 4, 10, 3, 5, 9, 4, 6, 8};
private static final int[] INN_12_A = {7, 2, 4, 10, 3, 5, 9, 4, 6, 8};
private static final int[] INN_12_B = {3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8};
private Validators() {
}
/** Алгоритм Луна: номер платёжной карты, 13–19 цифр. */
public static boolean luhn(String value) {
int sum = 0;
int digits = 0;
boolean doubled = false;
for (int i = value.length() - 1; i >= 0; i--) {
char c = value.charAt(i);
if (!Character.isDigit(c)) {
continue;
}
int d = c - '0';
digits++;
if (doubled) {
d *= 2;
if (d > 9) {
d -= 9;
}
}
sum += d;
doubled = !doubled;
}
return digits >= 13 && digits <= 19 && sum % 10 == 0;
}
/** Контрольная сумма ИНН: 10 знаков у юрлица, 12 у физлица. */
public static boolean inn(String value) {
int[] d = digits(value);
if (d.length == 10) {
return d[9] == checksum(d, INN_10);
}
if (d.length == 12) {
return d[10] == checksum(d, INN_12_A) && d[11] == checksum(d, INN_12_B);
}
return false;
}
/** Контрольная сумма СНИЛС: 11 знаков, последние два — контрольные. */
public static boolean snils(String value) {
int[] d = digits(value);
if (d.length != 11) {
return false;
}
int sum = 0;
for (int i = 0; i < 9; i++) {
sum += d[i] * (9 - i);
}
int control = snilsControl(sum);
return control == d[9] * 10 + d[10];
}
/** Контрольное число СНИЛС по сумме первых девяти цифр. */
private static int snilsControl(int sum) {
if (sum < 100) {
return sum;
}
if (sum == 100 || sum == 101) {
return 0;
}
return sum % 101 % 100;
}
/** Контрольная сумма ОГРН: первые 12 цифр по модулю 11, младший разряд — 13-я цифра. */
public static boolean ogrn(String value) {
int[] d = digits(value);
return d.length == 13 && d[12] == modReduce(d, 12, 11);
}
/** Контрольная сумма ОГРНИП: первые 14 цифр по модулю 13, младший разряд — 15-я цифра. */
public static boolean ogrnip(String value) {
int[] d = digits(value);
return d.length == 15 && d[14] == modReduce(d, 14, 13);
}
/**
* Остаток от деления первых {@code count} цифр как одного числа на {@code divisor},
* взятый по младшему разряду. Числовое накопление по цифрам, а не парсинг строки
* в {@code long}: у ОГРНИП 14 цифр — на грани переполнения {@code int}, и это тот же
* приём, что уже применяется к самой длинной последовательности в {@link #luhn}.
*/
private static int modReduce(int[] d, int count, int divisor) {
long remainder = 0;
for (int i = 0; i < count; i++) {
remainder = (remainder * 10 + d[i]) % divisor;
}
return (int) (remainder % 10);
}
/**
* Дата в числовой записи при любом порядке частей: {@code 12.05.1985},
* {@code 05/12/1985}, {@code 1985-05-12}. Отсекает похожие по форме
* последовательности вроде {@code 192.168.1}.
*/
public static boolean date(String value) {
// Запись с названием месяца словом в дополнительной проверке не нуждается:
// «мая» само по себе однозначно указывает на дату.
for (int i = 0; i < value.length(); i++) {
if (Character.isLetter(value.charAt(i))) {
return true;
}
}
String[] parts = value.split("[.\\-/]");
if (parts.length != 3) {
return false;
}
int[] n = new int[3];
for (int i = 0; i < 3; i++) {
if (parts[i].isEmpty() || parts[i].length() > 4) {
return false;
}
n[i] = Integer.parseInt(parts[i]);
}
for (int y = 0; y < 3; y++) {
if (parts[y].length() == 4) {
return n[y] >= 1900 && n[y] <= 2100 && dayAndMonth(n[(y + 1) % 3], n[(y + 2) % 3]);
}
}
// Год записан двумя цифрами: достаточно, чтобы день и месяц нашлись в любой паре.
return dayAndMonth(n[0], n[1]) || dayAndMonth(n[1], n[2]) || dayAndMonth(n[0], n[2]);
}
/** Пара чисел похожа на «день и месяц» в любом порядке. */
private static boolean dayAndMonth(int a, int b) {
return (a >= 1 && a <= 31 && b >= 1 && b <= 12) || (b >= 1 && b <= 31 && a >= 1 && a <= 12);
}
private static int checksum(int[] d, int[] weights) {
int sum = 0;
for (int i = 0; i < weights.length; i++) {
sum += d[i] * weights[i];
}
return sum % 11 % 10;
}
private static int[] digits(String value) {
int[] out = new int[value.length()];
int n = 0;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (Character.isDigit(c)) {
out[n++] = c - '0';
}
}
int[] trimmed = new int[n];
System.arraycopy(out, 0, trimmed, 0, n);
return trimmed;
}
}
@@ -0,0 +1,159 @@
package ru.pdguard.detect;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Разбиение текста на подслова так, как это делает токенизатор BERT.
*
* <p>Своя реализация вместо готовой библиотеки: единственная альтернатива на Java
* подтягивает нативные библиотеки во время работы, а контейнер должен подниматься
* без обращений в сеть. Правила здесь простые и целиком описаны форматом словаря:
* разбить по пробелам и знакам препинания, затем каждое слово — жадно по самой
* длинной подходящей записи словаря, продолжения помечаются префиксом «##».
*
* <p>Для каждого подслова сохраняются границы в исходном тексте: без них разметку
* модели не перенести обратно на строку.
*/
final class WordPiece {
/** Слово длиннее этого целиком заменяется на «неизвестно» — правило BERT. */
private static final int MAX_WORD_CHARS = 100;
private static final String CONTINUATION = "##";
/** Подслово и его границы в исходном тексте. */
record Piece(int id, int start, int end) {
}
private final Map<String, Integer> vocabulary;
private final int unknownId;
private final int classifyId;
private final int separatorId;
private WordPiece(Map<String, Integer> vocabulary) {
this.vocabulary = vocabulary;
this.unknownId = required(vocabulary, "[UNK]");
this.classifyId = required(vocabulary, "[CLS]");
this.separatorId = required(vocabulary, "[SEP]");
}
static WordPiece fromVocabulary(Path vocabularyFile) throws IOException {
Map<String, Integer> vocabulary = HashMap.newHashMap(140_000);
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(Files.newInputStream(vocabularyFile), StandardCharsets.UTF_8))) {
String line;
int index = 0;
while ((line = reader.readLine()) != null) {
vocabulary.putIfAbsent(line.strip(), index++);
}
}
return new WordPiece(vocabulary);
}
int classifyId() {
return classifyId;
}
int separatorId() {
return separatorId;
}
/** Подслова текста в порядке следования; служебные токены сюда не входят. */
List<Piece> split(String text, int maxPieces) {
List<Piece> pieces = new ArrayList<>();
for (int[] word : words(text)) {
if (pieces.size() >= maxPieces) {
break;
}
splitWord(text, word[0], word[1], pieces, maxPieces);
}
return pieces;
}
/**
* Границы слов: разделителями считаются пробельные символы и знаки препинания,
* причём знак препинания сам становится отдельным словом.
*/
private static List<int[]> words(String text) {
List<int[]> result = new ArrayList<>();
int start = -1;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
boolean separator = Character.isWhitespace(c) || isPunctuation(c);
if (separator) {
if (start >= 0) {
result.add(new int[]{start, i});
start = -1;
}
if (isPunctuation(c)) {
result.add(new int[]{i, i + 1});
}
} else if (start < 0) {
start = i;
}
}
if (start >= 0) {
result.add(new int[]{start, text.length()});
}
return result;
}
private static boolean isPunctuation(char c) {
if (Character.isLetterOrDigit(c)) {
return false;
}
return !Character.isWhitespace(c);
}
private void splitWord(String text, int from, int to, List<Piece> sink, int maxPieces) {
if (to - from > MAX_WORD_CHARS) {
sink.add(new Piece(unknownId, from, to));
return;
}
int cursor = from;
List<Piece> ofThisWord = new ArrayList<>();
while (cursor < to) {
int end = to;
Integer id = null;
while (end > cursor) {
String candidate = text.substring(cursor, end);
String lookup = cursor == from ? candidate : CONTINUATION + candidate;
id = vocabulary.get(lookup);
if (id != null) {
break;
}
end--;
}
if (id == null) {
// Ни одна часть слова не нашлась — слово целиком неизвестно.
sink.add(new Piece(unknownId, from, to));
return;
}
ofThisWord.add(new Piece(id, cursor, end));
cursor = end;
}
for (Piece piece : ofThisWord) {
if (sink.size() >= maxPieces) {
return;
}
sink.add(piece);
}
}
private static int required(Map<String, Integer> vocabulary, String token) {
Integer id = vocabulary.get(token);
if (id == null) {
throw new IllegalStateException("В словаре нет служебного токена " + token);
}
return id;
}
}
@@ -0,0 +1,46 @@
package ru.pdguard.mask;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.BiFunction;
/**
* Состояние одной операции маскирования.
*
* <p>Одинаковые значения в пределах запроса получают одинаковую замену: если
* клиент упомянут дважды, в тексте дважды окажется {@code [FIO_1]}, и смысл
* запроса для модели сохранится.
*
* <p>Экземпляр живёт в рамках одного вызова и между потоками не разделяется.
*/
public final class MaskContext {
/** Разделитель ключа; в названии типа ПД этот знак не встречается. */
private static final char SEPARATOR = '#';
private final Map<String, String> assigned = new HashMap<>();
private final Map<String, Integer> counters = new HashMap<>();
private final Map<String, String> restorations = new LinkedHashMap<>();
/**
* Замена для значения; при повторе возвращается ранее выданная.
*
* @param factory получает тип ПД и порядковый номер значения этого типа
*/
public String resolve(String type, String value, BiFunction<String, Integer, String> factory) {
return assigned.computeIfAbsent(type + SEPARATOR + value, key -> {
String replacement = factory.apply(type, counters.merge(type, 1, Integer::sum));
restorations.put(replacement, value);
return replacement;
});
}
/**
* Чем заменять обратно: подстановка к исходному значению. Нужно там, где текст
* возвращается не целиком, а изменённым — например, в ответе языковой модели.
*/
public Map<String, String> restorations() {
return Map.copyOf(restorations);
}
}
@@ -0,0 +1,14 @@
package ru.pdguard.mask;
/** Чем заменяется найденное значение. Выбирается настройками системы-потребителя. */
public enum MaskMode {
/** Звёздочки с сохранением длины и разделителей: {@code 45** ****56}. */
MASK,
/** Порядковый токен: {@code [FIO_1]}. Компактно и однозначно обратимо. */
TOKEN,
/** Правдоподобная подстановка: вместо настоящего имени — вымышленное. */
SYNTHETIC
}
+82
View File
@@ -0,0 +1,82 @@
package ru.pdguard.mask;
import org.springframework.stereotype.Component;
import ru.pdguard.detect.RuleRegistry;
import java.util.Map;
import java.util.function.UnaryOperator;
/**
* Превращает найденное значение в замену согласно настройкам системы.
*
* <p>Тип, для которого вид маски не задан, скрывается звёздочками целиком —
* безопасное поведение по умолчанию для вновь добавленных правил.
*/
@Component
public class Masker {
private static final UnaryOperator<String> EDGES = v -> Strategies.keepEdges(v, 2, 2);
private static final UnaryOperator<String> SHORT_SERIES = v -> Strategies.keepEdges(v, 0, 2);
private static final Map<String, UnaryOperator<String>> BY_TYPE = Map.ofEntries(
Map.entry(RuleRegistry.EMAIL, Strategies::email),
Map.entry(RuleRegistry.PHONE, EDGES),
Map.entry(RuleRegistry.CARD, EDGES),
Map.entry(RuleRegistry.INN, EDGES),
Map.entry(RuleRegistry.SNILS, EDGES),
Map.entry(RuleRegistry.PASSPORT, EDGES),
Map.entry(RuleRegistry.DRIVER_LICENSE, EDGES),
Map.entry(RuleRegistry.DEPT_CODE, EDGES),
// У этих документов серия короткая — две цифры или две буквы. Оставь мы
// первые два знака, серия оказалась бы открыта целиком, поэтому видны
// только последние. У паспорта РФ и водительского удостоверения серия
// из четырёх знаков, там открывается половина.
Map.entry(RuleRegistry.FOREIGN_PASSPORT, SHORT_SERIES),
Map.entry(RuleRegistry.MILITARY_ID, SHORT_SERIES),
Map.entry(RuleRegistry.BIRTH_CERTIFICATE, SHORT_SERIES),
Map.entry(RuleRegistry.MEDICAL_POLICY, EDGES),
Map.entry(RuleRegistry.CARDHOLDER, Strategies::initials),
Map.entry(RuleRegistry.FIO, Strategies::initials),
// Код проверки и пин-код не показываем даже частично: у них слишком
// мало знаков, чтобы открывать хотя бы один.
Map.entry(RuleRegistry.CVV, Strategies::stars),
Map.entry(RuleRegistry.PIN, Strategies::stars),
Map.entry(RuleRegistry.PASSPORT_ISSUER, Strategies::stars),
// У дат сохраняем разделители: модель видит, что это дата, но не какая.
Map.entry(RuleRegistry.BIRTH_DATE, Strategies::starsKeepingPunctuation),
Map.entry(RuleRegistry.PASSPORT_DATE, Strategies::starsKeepingPunctuation),
Map.entry(RuleRegistry.DATE, Strategies::starsKeepingPunctuation),
Map.entry(RuleRegistry.ADDRESS_COUNTRY, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_POSTCODE, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_CITY, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_STREET, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_HOUSE, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_FLAT, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_REGION, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_DISTRICT, Strategies::stars),
Map.entry(RuleRegistry.BIRTH_PLACE, Strategies::stars),
Map.entry(RuleRegistry.CITIZENSHIP, Strategies::stars),
Map.entry(RuleRegistry.ACCOUNT_NUMBER, EDGES),
Map.entry(RuleRegistry.OGRN, EDGES),
Map.entry(RuleRegistry.OGRNIP, EDGES),
Map.entry(RuleRegistry.KPP, EDGES),
// Срок действия карты — разделитель виден, сам месяц/год нет.
Map.entry(RuleRegistry.CARD_EXPIRY, Strategies::starsKeepingPunctuation),
Map.entry(RuleRegistry.BIK, Strategies::stars),
Map.entry(RuleRegistry.INCOME, Strategies::stars),
Map.entry(RuleRegistry.BIOMETRIC, Strategies::stars)
);
public String mask(String type, String value, MaskMode mode, MaskContext context) {
return switch (mode) {
case MASK -> BY_TYPE.getOrDefault(type, Strategies::stars).apply(value);
case TOKEN -> context.resolve(type, value, (t, n) -> "[" + t + "_" + n + "]");
case SYNTHETIC -> context.resolve(type, value, (t, n) -> Synthetic.forType(t, value, n));
};
}
}
@@ -0,0 +1,115 @@
package ru.pdguard.mask;
/**
* Способы преобразования найденного значения в маску.
*
* <p>Все стратегии сохраняют длину и разделители исходного значения: так
* замаскированный текст остаётся читаемым для LLM и минимально отличается
* от эталона при посимвольном сравнении.
*/
public final class Strategies {
private static final char MASK = '*';
private Strategies() {
}
/** Каждый непробельный символ заменяется на «*». */
public static String stars(String value) {
StringBuilder sb = new StringBuilder(value.length());
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
sb.append(Character.isWhitespace(c) ? c : MASK);
}
return sb.toString();
}
/**
* Скрывает буквы и цифры, оставляя разделители: {@code 12.05.1985} → {@code **.**.****},
* {@code 12 мая 1985} → {@code ** *** ****}. Форма записи остаётся видна модели,
* само значение — нет.
*/
public static String starsKeepingPunctuation(String value) {
StringBuilder sb = new StringBuilder(value.length());
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
sb.append(Character.isLetterOrDigit(c) ? MASK : c);
}
return sb.toString();
}
/**
* Оставляет первые и последние значащие символы, остальные скрывает,
* разделители сохраняет: {@code 4509 123456} → {@code 45** ****56}.
*/
public static String keepEdges(String value, int head, int tail) {
int significant = 0;
for (int i = 0; i < value.length(); i++) {
if (Character.isLetterOrDigit(value.charAt(i))) {
significant++;
}
}
if (significant <= head + tail) {
return stars(value);
}
StringBuilder sb = new StringBuilder(value.length());
int seen = 0;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (!Character.isLetterOrDigit(c)) {
sb.append(c);
continue;
}
boolean visible = seen < head || seen >= significant - tail;
sb.append(visible ? c : MASK);
seen++;
}
return sb.toString();
}
/** ФИО превращается в инициалы: {@code Иванов Иван Иванович} → {@code И. И. И.} */
public static String initials(String value) {
StringBuilder sb = new StringBuilder();
boolean wordStart = true;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (Character.isLetter(c)) {
if (wordStart) {
if (!sb.isEmpty()) {
sb.append(' ');
}
sb.append(Character.toUpperCase(c)).append('.');
wordStart = false;
}
} else {
wordStart = true;
}
}
return sb.isEmpty() ? stars(value) : sb.toString();
}
/**
* Адрес почты: видны первая буква имени ящика, первая буква домена и зона.
* {@code ivan.petrov@mail.ru} → {@code i**********@m***.ru}
*/
public static String email(String value) {
int at = value.lastIndexOf('@');
if (at <= 0 || at == value.length() - 1) {
return stars(value);
}
String local = value.substring(0, at);
String domain = value.substring(at + 1);
int dot = domain.lastIndexOf('.');
if (dot <= 0) {
return hideTail(local) + '@' + hideTail(domain);
}
return hideTail(local) + '@' + hideTail(domain.substring(0, dot)) + domain.substring(dot);
}
private static String hideTail(String part) {
if (part.length() <= 1) {
return part;
}
return part.charAt(0) + String.valueOf(MASK).repeat(part.length() - 1);
}
}
@@ -0,0 +1,97 @@
package ru.pdguard.mask;
import ru.pdguard.detect.RuleRegistry;
/**
* Правдоподобные подставные значения вместо настоящих.
*
* <p>Модель получает текст, который выглядит естественно, и качество ответа
* страдает меньше, чем от звёздочек. Значения детерминированы: одно и то же
* исходное значение всегда даёт одну и ту же подстановку.
*/
final class Synthetic {
private static final String[] SURNAMES =
{"Лаврентьев", "Мещеряков", "Тихомиров", "Ясенев", "Бурмистров", "Кольцов"};
private static final String[] NAMES = {"Артём", "Никита", "Глеб", "Тимур", "Марк", "Лев"};
private static final String[] PATRONYMICS =
{"Артёмович", "Никитич", "Глебович", "Тимурович", "Маркович", "Львович"};
private static final String[] DOMAINS = {"example.com", "example.org", "example.net"};
private Synthetic() {
}
static String forType(String type, String value, int ordinal) {
int seed = value.hashCode() & Integer.MAX_VALUE;
return switch (type) {
case RuleRegistry.FIO -> pick(SURNAMES, seed) + " " + pick(NAMES, seed >> 3)
+ " " + pick(PATRONYMICS, seed >> 6);
case RuleRegistry.CARDHOLDER -> "IVAN PETROV";
case RuleRegistry.EMAIL -> "user" + ordinal + "@" + pick(DOMAINS, seed);
case RuleRegistry.PHONE -> "+7 9" + digits(seed, 2) + " " + digits(seed >> 4, 3)
+ "-" + digits(seed >> 8, 2) + "-" + digits(seed >> 12, 2);
case RuleRegistry.CARD -> luhnCard(seed);
case RuleRegistry.PASSPORT, RuleRegistry.DRIVER_LICENSE, RuleRegistry.FOREIGN_PASSPORT,
RuleRegistry.MILITARY_ID -> digits(seed, 4) + " " + digits(seed >> 6, 6);
case RuleRegistry.INN -> digits(seed, 12);
case RuleRegistry.MEDICAL_POLICY -> digits(seed, 16);
case RuleRegistry.SNILS -> digits(seed, 3) + "-" + digits(seed >> 4, 3)
+ "-" + digits(seed >> 8, 3) + " " + digits(seed >> 12, 2);
case RuleRegistry.BIRTH_DATE, RuleRegistry.PASSPORT_DATE, RuleRegistry.DATE -> syntheticDate(seed);
case RuleRegistry.ADDRESS_CITY -> "Зареченск";
case RuleRegistry.ADDRESS_STREET -> "Сосновая";
case RuleRegistry.ADDRESS_HOUSE -> String.valueOf(1 + Math.floorMod(seed, 90));
case RuleRegistry.ADDRESS_FLAT -> String.valueOf(1 + Math.floorMod(seed, 200));
case RuleRegistry.ADDRESS_POSTCODE -> digits(seed, 6);
case RuleRegistry.ADDRESS_COUNTRY -> "Заречье";
case RuleRegistry.ADDRESS_REGION -> "Заречная область";
case RuleRegistry.ADDRESS_DISTRICT -> "Сосновый район";
case RuleRegistry.CVV -> digits(seed, 3);
case RuleRegistry.PIN -> digits(seed, 4);
// Для остальных типов правдоподобной замены нет — отдаём токен.
default -> "[" + type + "_" + ordinal + "]";
};
}
private static String pick(String[] options, int seed) {
return options[Math.floorMod(seed, options.length)];
}
private static String syntheticDate(int seed) {
int day = 1 + Math.floorMod(seed, 28);
int month = 1 + Math.floorMod(seed >> 5, 12);
int year = 1960 + Math.floorMod(seed >> 9, 45);
return String.format("%02d.%02d.%d", day, month, year);
}
private static String digits(int seed, int count) {
StringBuilder sb = new StringBuilder(count);
int value = Math.abs(seed);
for (int i = 0; i < count; i++) {
sb.append((char) ('0' + Math.floorMod(value, 10)));
value = value / 10 + (i + 1) * 7;
}
return sb.toString();
}
/** Номер карты, проходящий проверку алгоритмом Луна: подстановка должна выглядеть настоящей. */
private static String luhnCard(int seed) {
StringBuilder body = new StringBuilder("4").append(digits(seed, 14));
int sum = 0;
boolean doubled = true;
for (int i = body.length() - 1; i >= 0; i--) {
int d = body.charAt(i) - '0';
if (doubled) {
d *= 2;
if (d > 9) {
d -= 9;
}
}
sum += d;
doubled = !doubled;
}
body.append((10 - sum % 10) % 10);
return body.substring(0, 4) + " " + body.substring(4, 8) + " "
+ body.substring(8, 12) + " " + body.substring(12);
}
}
+52
View File
@@ -0,0 +1,52 @@
server:
port: 8080
address: 0.0.0.0
max-http-request-header-size: 16KB
spring:
application:
name: pd-guard-spring
jackson:
default-property-inclusion: non_null
data:
redis:
host: localhost
port: 6379
timeout: 200ms
connect-timeout: 200ms
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
endpoint:
health:
probes:
enabled: true
prometheus:
metrics:
export:
enabled: true
# Список систем-потребителей. Файла нет — работают настройки по умолчанию.
pdguard:
systems-file: config/systems.json
store:
backend: memory
max-chars: 134217728
ttl-minutes: 30
min-concurrent: 8
max-concurrent: 2000
target-latency-ms: 200
warmup-iterations: 2000
ner:
engine: off
model: models/rubert-ner
max-candidates: 16
pool-size: 16
llm:
url:
api-key:
model: gpt-4o-mini
timeout-seconds: 20
+167
View File
@@ -0,0 +1,167 @@
# Основы русских личных имён. Правило дописывает до трёх строчных букв,
# поэтому падежные формы (Ивану, Иваном, Ивана) покрываются основой.
# Строка — одна основа; строки с # игнорируются.
Александр
Алексей
Анатолий
Андрей
Антон
Аркадий
Арсений
Артём
Артем
Артур
Богдан
Борис
Вадим
Валентин
Валерий
Василий
Виктор
Виталий
Владимир
Владислав
Вячеслав
Геннадий
Георгий
Герман
Глеб
Григорий
Даниил
Данил
Денис
Дмитрий
Евгений
Егор
Иван
Игорь
Илья
Кирилл
Константин
Леонид
Максим
Марк
Матвей
Михаил
Никита
Николай
Олег
Павел
Пётр
Петр
Роман
Руслан
Семён
Семен
Сергей
Станислав
Степан
Тимофей
Тимур
Фёдор
Федор
Эдуард
Юрий
Ярослав
Алёна
Алена
Алина
Алла
Анастасия
Ангелина
Анна
Антонина
Валентина
Валерия
Варвара
Вероника
Виктория
Галина
Дарья
Диана
Евгения
Екатерина
Елена
Елизавета
Жанна
Зинаида
Инна
Ирина
Карина
Кристина
Ксения
Лариса
Лидия
Любовь
Людмила
Маргарита
Марина
Мария
Надежда
Наталья
Наталия
Нина
Оксана
Ольга
Полина
Раиса
Регина
Светлана
София
Софья
Тамара
Татьяна
Ульяна
Юлия
Лев
Яков
Ян
Захар
Тарас
Савва
Мирон
Демид
Клим
Влас
Родион
Святослав
Всеволод
Игнат
Филипп
Лука
Назар
Платон
Прохор
Трофим
Фома
Эмиль
Юлиан
Тихон
Гавриил
Давид
Марат
Рустам
Яна
Алиса
Василиса
Агата
Злата
Милана
Дарина
Есения
Таисия
Инга
Вера
Эмма
Нелли
Алевтина
Клавдия
Лилия
Римма
Элина
Ева
Аделина
Амина
Динара
Лейла
Сабина
@@ -0,0 +1,79 @@
# Слова, после которых идущее следом имя принадлежит организации, учреждению или
# объекту на карте, а не человеку: «Институт Склифосовского», «Музей Тропинина»,
# «улица Королёва», «Премия имени Ломоносова».
#
# Здесь не названия, а маркеры. Слово засчитывается только вплотную перед именем,
# поэтому «Больница приняла Иванова Ивана» под правило не попадает.
#
# «ИП» сюда сознательно не входит: имя индивидуального предпринимателя —
# это персональные данные.
институт
университет
академия
школа
гимназия
лицей
училище
колледж
музей
театр
галерея
библиотека
филармония
консерватория
больница
поликлиника
клиника
госпиталь
диспансер
санаторий
фонд
премия
стипендия
стадион
клуб
общество
союз
ассоциация
федерация
комитет
министерство
ведомство
департамент
управление
агентство
бюро
корпорация
холдинг
компания
завод
комбинат
фабрика
верфь
аэропорт
вокзал
станция
порт
парк
сквер
площадь
проспект
улица
переулок
бульвар
шоссе
набережная
проезд
тупик
мост
тоннель
храм
собор
монастырь
часовня
кладбище
мемориал
памятник
монумент
центр
имени
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
# Известные исторические и культурные фигуры. Упоминание такого имени
# персональными данными не является — если рядом нет ПД другого типа.
# Сравнение идёт по началу слова, поэтому падежи покрываются основой.
Пушкин
Лермонтов
Толстой
Достоевский
Гоголь
Чехов
Тургенев
Некрасов
Есенин
Маяковский
Ахматова
Цветаева
Булгаков
Пастернак
Чайковский
Ломоносов
Менделеев
Гагарин
Королёв
Суворов
Кутузов
Шекспир
Эйнштейн
Ньютон
Моцарт
Бетховен
Рахманинов
Репин
Айвазовский
Циолковский
Онегин
Печорин
Раскольников
Обломов
Чичиков
Базаров
Болконский
Каренин
Чацкий
Мцыри
Хлестаков
Митрофанушка
# Действующие публичные фигуры — упоминание в новостном/служебном контексте
# («Президент подписал закон», «ЦБ во главе с Набиуллиной повысил ставку»)
# персональными данными клиента не является.
Путин
Мишустин
Набиуллина
Греф
Костин
Тиньков
Дуров
Мордашов
Потанин
Дерипаска
Абрамович
Усманов
# Фамилии, в честь которых чаще всего называют улицы в России (Росреестр,
# Яндекс.Исследования). Упоминание «улица Ленина» само по себе не задевает
# распознавание ФИО — для него нужны два слова, — но составные названия
# («Феликса Дзержинского», «Александра Матросова») попадают под то же
# правило, что и «Богдана Хмельницкого»: в честь человека, а не клиент.
Ленин
Киров
Свердлов
Дзержинский
Фрунзе
Куйбышев
Ворошилов
Будённый
Буденный
Чапаев
Жуков
Чкалов
Терешкова
Мичурин
Шевченко
Орджоникидзе
Калинин
Энгельс
Маркс
Островский
Матросов
Волошина
Сусанин
Димитров
Донской
+27
View File
@@ -0,0 +1,27 @@
{
"default": {
"enabled": true,
"demask": true,
"maskMode": "MASK",
"types": ["*"],
"requireCompanion": ["CVV", "PIN", "DATE"]
},
"crm": {
"enabled": true,
"demask": false,
"maskMode": "TOKEN",
"types": ["FIO", "PHONE", "EMAIL", "ADDRESS_CITY", "ADDRESS_STREET", "ADDRESS_HOUSE", "ADDRESS_FLAT"]
},
"analytics": {
"enabled": true,
"demask": false,
"maskMode": "SYNTHETIC",
"types": ["*"]
},
"legacy-billing": {
"enabled": false,
"demask": false,
"maskMode": "MASK",
"types": ["*"]
}
}
+155
View File
@@ -0,0 +1,155 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/** Банковские реквизиты сверх платёжной карты: счёт, БИК, ОГРН(ИП), КПП, доход, биометрия. */
class BankTypesTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
private void assertHidden(String text, String secret) {
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
/**
* Банковские реквизиты маскируются рядом с данными человека. Сами по себе они
* опознают организацию или счёт, а не клиента, и в перечне типов из задания их
* нет — поэтому они переведены в {@code requireCompanion}, как пин-код и дата.
*/
@Test
void masksAccountNumberNextToPersonalData() {
assertHidden("Клиент Иванов Иван Иванович, расчётный счёт 40702810500000001234",
"40702810500000001234");
assertHidden("Иванов И.И., р/с 4070 2810 5000 0000 1234", "4070 2810 5000 0000 1234");
}
@Test
void masksBikNextToPersonalData() {
assertHidden("Перевод Иванову Ивану Ивановичу, БИК 044525593 банка-получателя", "044525593");
}
@Test
void keepsBankDetailsWithoutAnyPersonalData() {
for (String text : new String[]{
"Расчётный счёт 40702810500000001234 открыт вчера",
"БИК 044525593 банка-получателя",
"ОГРН 1027700132195 организации",
"КПП 770101001 указан в реквизитах"}) {
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"реквизиты без человека персональными данными не являются");
}
}
@Test
void masksCardExpiryButNotCardNumber() {
String masked = pipeline.process(
"Карта 4111 1111 1111 1111, срок действия 09/27", "expiry-1", SystemPolicy.DEFAULT);
assertFalse(masked.contains("09/27"), masked);
assertEquals("Карта 41** **** **** **11, срок действия **/**", masked);
}
@Test
void masksOgrnAndOgrnipDifferently() {
assertHidden("Директор Иванов И.И., ОГРН 1027700132195 организации", "1027700132195");
assertHidden("ИП Иванов Иван Иванович, ОГРНИП 304500116000157", "304500116000157");
}
/** ОГРНИП (15 цифр) не должен наполовину ловиться правилом ОГРН (13 цифр). */
@Test
void ogrnDoesNotSwallowOgrnip() {
String masked = pipeline.process("ИП Иванов Иван Иванович, ОГРНИП 304500116000157",
"ogrnip-1", SystemPolicy.DEFAULT);
assertFalse(masked.contains("304500116000157"), masked);
assertFalse(masked.matches(".*\\d{15}.*"), "осталась незамаскированная часть номера: " + masked);
}
/**
* Контрольная сумма отсекает случайное 13-значное число рядом со словом «ОГРН».
* Число подобрано так, чтобы не проходить заодно и Луна — иначе оно всё равно
* маскировалось бы, но уже как номер карты, и тест ничего бы не показывал.
*/
@Test
void doesNotMaskOgrnWithBrokenChecksum() {
String text = "ОГРН 1027700132190 организации";
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"число с неверной контрольной суммой не является настоящим ОГРН");
}
/** Та же проверка для ОГРНИП — случайное 15-значное число рядом со словом. */
@Test
void doesNotMaskOgrnipWithBrokenChecksum() {
String text = "ОГРНИП 304500116000150 предпринимателя";
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"число с неверной контрольной суммой не является настоящим ОГРНИП");
}
@Test
void masksKppNextToPersonalData() {
assertHidden("Заявитель Иванов И.И., КПП 770101001 указан в реквизитах", "770101001");
}
@Test
void masksIncomeNextToPersonalData() {
assertHidden("Иванов Иван Иванович, доход 85 000 руб. в месяц", "85 000");
assertHidden("Иванову И.И. начислена заработная плата 120000 в месяц", "120000");
}
/**
* Сумма заработка без человека — статистика или описание продукта. Опознать по
* ней никого нельзя, а для прокси к языковой модели вымаранное число означает,
* что вопрос про среднюю зарплату по отрасли отвечать уже не на чем.
*/
@Test
void keepsIncomeWithoutAnyPersonalData() {
for (String text : new String[]{
"По данным Росстата доход домохозяйств вырос до 74 500 руб",
"Зарплатный проект: зарплата 80 000 руб перечисляется на счёт"}) {
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"сумма заработка без человека персональными данными не является");
}
}
@Test
void masksBiometricMentionNextToPersonalData() {
assertHidden("Клиент Иванов Иван Иванович сдал биометрические данные", "биометрические данные");
assertHidden("Для Иванова И.И. оформлен слепок голоса", "слепок голоса");
}
/**
* Биометрии в тексте не бывает: это шаблон в базе, и правило маскирует само
* упоминание — слово, а не данные. Без человека рядом такая замена скрывает
* ноль сведений и разрушает смысл фразы.
*/
@Test
void keepsBiometricMentionWithoutAnyPersonalData() {
for (String text : new String[]{
"Банк внедрил биометрические данные в обслуживание клиентов",
"Сдать биометрию можно через ЕБС в любом отделении"}) {
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"упоминание биометрии без человека персональными данными не является");
}
}
/**
* Два несамостоятельных типа рядом не заверяют друг друга: сочетание даты и
* ОГРН самостоятельным не становится, человека в таком тексте нет.
*/
@Test
void twoCompanionTypesDoNotVouchForEachOther() {
String text = "Оплата 01.02.2025, ОГРН 1027700132195";
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"спутники заверили друг друга в отсутствие настоящих ПД");
}
}
@@ -0,0 +1,67 @@
package ru.pdguard;
import ru.pdguard.detect.Span;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Общий разбор размеченных наборов {@code {{ТИП:значение}}} — используется
* и {@link BenchmarkTest} (замер качества по строкам), и {@link LargeTextTest}
* (те же строки, перемешанные и склеенные в большой текст).
*/
final class BenchmarkFixtures {
private static final Pattern MARKUP = Pattern.compile("\\{\\{([A-Z_]+):([^}]*)}}");
/** Размеченный пример: чистый текст и эталонные фрагменты. */
record Sample(String text, List<Span> gold) {
}
private BenchmarkFixtures() {
}
/** Читает набор построчно, пропуская пустые строки и комментарии {@code #}. */
static List<Sample> load(String resource) {
List<Sample> samples = new ArrayList<>();
try (InputStream in = BenchmarkFixtures.class.getResourceAsStream(resource);
BufferedReader reader = new BufferedReader(
new InputStreamReader(Objects.requireNonNull(in, resource), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
String trimmed = line.trim();
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
samples.add(parse(trimmed));
}
}
} catch (IOException e) {
throw new IllegalStateException("Не удалось прочитать " + resource, e);
}
return samples;
}
/** Разбирает разметку {@code {{ТИП:значение}}} в чистый текст и эталонные фрагменты. */
static Sample parse(String line) {
StringBuilder text = new StringBuilder(line.length());
List<Span> gold = new ArrayList<>();
Matcher m = MARKUP.matcher(line);
int cursor = 0;
while (m.find()) {
text.append(line, cursor, m.start());
int start = text.length();
text.append(m.group(2));
gold.add(new Span(start, text.length(), m.group(1), 0));
cursor = m.end();
}
text.append(line, cursor, line.length());
return new Sample(text.toString(), gold);
}
}
+364
View File
@@ -0,0 +1,364 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.Span;
import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/**
* Замер качества детекции на размеченных наборах.
*
* <p>Наборов два. {@code benchmark.txt} использовался при отладке правил, поэтому
* его оценка завышена и годится только как защита от ухудшений.
* {@code benchmark-holdout.txt} составлен независимо и на нём правила не
* настраивались — именно он показывает настоящее качество.
*
* <p>Метрики посимвольные: так они не зависят от того, где именно правило
* поставило границу совпадения, и напрямую соотносятся с посимвольным
* сравнением замаскированного текста с эталоном.
*
* <p>Отдельно считается строка «любой тип»: для защиты важно, что знаки скрыты,
* а расхождение в названии типа (скажем, место рождения против города) на
* качество маскирования не влияет.
*/
class BenchmarkTest {
/**
* Вторая ступень для замера. Модели нет — прогон идёт на одних правилах, и это
* видно по заголовку отчёта. Путь подменяется свойством {@code -Dbench.model=...}.
*/
private static final String ENGINE = System.getProperty("bench.engine", "rubert");
private static final String MODEL_PATH = System.getProperty("bench.model", "models/rubert-ner");
/** Итог замера по одному набору. */
private record Result(double fioF1, double overallPrecision, double overallRecall,
double falsePositiveRate, int foundFioSpans, int goldFioSpans) {
}
/** Накопитель посимвольных совпадений по одному типу. */
private static final class Score {
private int truePositive;
private int falsePositive;
private int falseNegative;
private int gold() {
return truePositive + falseNegative;
}
private double precision() {
int found = truePositive + falsePositive;
return found == 0 ? 1.0 : (double) truePositive / found;
}
private double recall() {
return gold() == 0 ? 1.0 : (double) truePositive / gold();
}
private double f1() {
double p = precision();
double r = recall();
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
}
}
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30));
/**
* Набор, на котором правила отлаживались. Пороги здесь высокие: любое падение
* означает, что сломалось то, что раньше работало.
*/
@Test
void detectionQualityOnTuningSet() {
Result result = measure("/benchmark.txt", "набор отладки");
assertTrue(result.fioF1() >= 0.95,
String.format("F1 по ФИО упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.95,
String.format("полнота по всем типам упала до %.3f", result.overallRecall()));
assertTrue(result.falsePositiveRate() <= 0.05,
String.format("ложные срабатывания на чистых текстах: %.3f", result.falsePositiveRate()));
}
/**
* Отложенный набор: правила на нём не настраивались. Пороги ниже — они
* отражают измеренное на нём качество, а не желаемое.
*/
@Test
void detectionQualityOnHoldoutSet() {
Result result = measure("/benchmark-holdout.txt", "отложенный набор");
assertTrue(result.fioF1() >= 0.75,
String.format("F1 по ФИО на отложенном наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.75,
String.format("полнота на отложенном наборе упала до %.3f", result.overallRecall()));
assertTrue(result.falsePositiveRate() <= 0.15,
String.format("ложные срабатывания на отложенном наборе: %.3f", result.falsePositiveRate()));
}
/**
* Второй контрольный набор, составленный после того, как первый дважды повлиял
* на правила. На нём не настраивалось ничего — он и показывает настоящее
* качество. Пороги низкие намеренно: тест ловит обвал, а не сторожит значение.
*/
@Test
void detectionQualityOnThirdHoldoutSet() {
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH))
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30),
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
: pipeline;
Result result = measure(stage, "/benchmark-holdout3.txt", "второй контрольный набор");
assertTrue(result.fioF1() >= 0.70,
String.format("F1 по ФИО на втором контрольном наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.70,
String.format("полнота на втором контрольном наборе упала до %.3f", result.overallRecall()));
}
/**
* Контрольный набор. Правила по нему не настраиваются: он существует, чтобы
* показывать качество на данных, которых разработка не видела. Пороги здесь
* низкие намеренно — тест ловит обвал, а не сторожит достигнутое значение.
* Замер идёт со второй ступенью, если модель собрана, иначе на одних правилах.
*/
@Test
void detectionQualityOnSecondHoldoutSet() {
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH))
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30),
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
: pipeline;
Result result = measure(stage, "/benchmark-holdout2.txt", "второй отложенный набор");
assertTrue(result.fioF1() >= 0.70,
String.format("F1 по ФИО на втором отложенном наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.70,
String.format("полнота на втором отложенном наборе упала до %.3f", result.overallRecall()));
}
/**
* Реальные адреса отделений Альфа-Банка (ловушка из ТЗ — не ПД клиента),
* расширенный денилист, обобщённое companion-правило (место рождения,
* страна) и новые банковские типы. Собран специально под соответствующие
* доработки — пороги ниже, чем у набора отладки, но проверяют именно то,
* что было доработано, а не общее качество остального пайплайна.
*/
@Test
void detectionQualityOnBankContextSet() {
Result result = measure("/benchmark-bank-context.txt", "банковский контекст");
assertTrue(result.fioF1() >= 0.70,
String.format("F1 по ФИО на банковском наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.70,
String.format("полнота на банковском наборе упала до %.3f", result.overallRecall()));
assertTrue(result.falsePositiveRate() <= 0.10,
String.format("ложные срабатывания на банковском наборе: %.3f", result.falsePositiveRate()));
}
/**
* Независимый сгенерированный набор — покрывает все типы ПД из ТЗ и вариации
* написания, не встречавшиеся ни в одном из остальных наборов. Правила под
* него не настраивались; пороги низкие по той же причине, что и у второго
* отложенного набора — тест ловит обвал, а не сторожит достигнутое значение.
*/
@Test
void detectionQualityOnGeneratedSet() {
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH))
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30),
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
: pipeline;
Result result = measure(stage, "/benchmark-generated.txt", "сгенерированный набор");
assertTrue(result.fioF1() >= 0.70,
String.format("F1 по ФИО на сгенерированном наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.70,
String.format("полнота на сгенерированном наборе упала до %.3f", result.overallRecall()));
}
/**
* Тот же отложенный набор, но со включённой второй ступенью. Модели нет —
* проверка пропускается: в сборке без модели сервис работает на одних правилах.
*/
@Test
void detectionQualityWithNameCascade() {
Path model = Path.of(MODEL_PATH);
assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана");
Pipeline withCascade = new Pipeline(new RuleRegistry(), new Masker(),
new PayloadStore(10_000_000L, 30), new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4));
Result result = measure(withCascade, "/benchmark-holdout.txt", "отложенный набор, вторая ступень включена");
assertTrue(result.fioF1() >= 0.75,
String.format("F1 по ФИО со второй ступенью упал до %.3f", result.fioF1()));
}
private Result measure(String resource, String title) {
return measure(pipeline, resource, title);
}
private Result measure(Pipeline stage, String resource, String title) {
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load(resource);
Map<String, Score> byType = new LinkedHashMap<>();
Score anyType = new Score();
int cleanTexts = 0;
int cleanTextsWithFalseHit = 0;
int goldFioSpans = 0;
int foundFioSpans = 0;
List<String> falseHits = new ArrayList<>();
List<String> missedFio = new ArrayList<>();
List<String> overMasked = new ArrayList<>();
for (BenchmarkFixtures.Sample sample : samples) {
List<Span> found = stage.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
String[] goldChars = paint(sample.text().length(), sample.gold());
String[] foundChars = paint(sample.text().length(), found);
for (int i = 0; i < sample.text().length(); i++) {
account(byType, goldChars[i], foundChars[i]);
accountAnyType(anyType, goldChars[i] != null, foundChars[i] != null);
}
if (sample.gold().isEmpty()) {
cleanTexts++;
if (!found.isEmpty()) {
cleanTextsWithFalseHit++;
falseHits.add(fragment(sample.text(), found.get(0)) + " ← " + sample.text());
}
} else {
collectOverMasked(sample.text(), goldChars, foundChars, overMasked);
}
for (Span gold : sample.gold()) {
if (!RuleRegistry.FIO.equals(gold.type())) {
continue;
}
goldFioSpans++;
if (overlappedByFio(gold, found)) {
foundFioSpans++;
} else {
missedFio.add(fragment(sample.text(), gold) + " ← " + sample.text());
}
}
}
report(title, samples.size(), byType, anyType, goldFioSpans, foundFioSpans,
cleanTexts, cleanTextsWithFalseHit, missedFio, falseHits, overMasked);
Score fio = byType.getOrDefault(RuleRegistry.FIO, new Score());
double falsePositiveRate = cleanTexts == 0 ? 0.0 : (double) cleanTextsWithFalseHit / cleanTexts;
return new Result(fio.f1(), anyType.precision(), anyType.recall(),
falsePositiveRate, foundFioSpans, goldFioSpans);
}
/** Раскрашивает каждый знак текста типом ПД, который его покрывает. */
private static String[] paint(int length, List<Span> spans) {
String[] painted = new String[length];
for (Span span : spans) {
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
painted[i] = span.type();
}
}
return painted;
}
private static void account(Map<String, Score> byType, String gold, String found) {
if (gold != null) {
Score score = byType.computeIfAbsent(gold, t -> new Score());
if (gold.equals(found)) {
score.truePositive++;
} else {
score.falseNegative++;
}
}
if (found != null && !found.equals(gold)) {
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
}
}
private static void accountAnyType(Score score, boolean gold, boolean found) {
if (gold && found) {
score.truePositive++;
} else if (gold) {
score.falseNegative++;
} else if (found) {
score.falsePositive++;
}
}
private static boolean overlappedByFio(Span gold, List<Span> found) {
return found.stream()
.anyMatch(span -> RuleRegistry.FIO.equals(span.type()) && span.overlaps(gold));
}
private static String fragment(String text, Span span) {
return "«" + text.substring(span.start(), Math.min(span.end(), text.length())) + "»";
}
/** Знаки, замаскированные сверх эталона: полезно видеть, где правило берёт лишнее. */
private static void collectOverMasked(String text, String[] gold, String[] found, List<String> sink) {
int from = -1;
for (int i = 0; i <= text.length(); i++) {
boolean extra = i < text.length() && found[i] != null && gold[i] == null;
if (extra && from < 0) {
from = i;
} else if (!extra && from >= 0) {
sink.add("«" + text.substring(from, i) + "» как " + found[from] + " ← " + text);
from = -1;
}
}
}
private void report(String title, int samples, Map<String, Score> byType, Score anyType,
int goldFio, int foundFio, int cleanTexts, int falseHitTexts,
List<String> missedFio, List<String> falseHits, List<String> overMasked) {
StringBuilder out = new StringBuilder(4096);
out.append("\n=== ").append(title).append(": ").append(samples).append(" размеченных строк ===\n\n");
out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
byType.entrySet().stream()
.sorted(Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
.forEach(e -> out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n",
e.getKey(), e.getValue().gold(), e.getValue().precision(),
e.getValue().recall(), e.getValue().f1())));
out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n", "ЛЮБОЙ ТИП", anyType.gold(),
anyType.precision(), anyType.recall(), anyType.f1()));
out.append(String.format("%nФИО пофрагментно: найдено %d из %d (%.1f %%)%n",
foundFio, goldFio, goldFio == 0 ? 100.0 : 100.0 * foundFio / goldFio));
out.append(String.format("Тексты без ПД: ложные срабатывания на %d из %d (%.1f %%)%n",
falseHitTexts, cleanTexts, cleanTexts == 0 ? 0.0 : 100.0 * falseHitTexts / cleanTexts));
appendList(out, "\nНе найденные ФИО:", missedFio);
appendList(out, "\nЛожные срабатывания:", falseHits);
appendList(out, "\nЗамаскировано сверх эталона:", overMasked);
System.out.println(out);
}
private static void appendList(StringBuilder out, String title, List<String> lines) {
if (lines.isEmpty()) {
return;
}
out.append(title).append('\n');
lines.forEach(line -> out.append(" ").append(line).append('\n'));
}
}
@@ -0,0 +1,162 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Типы ПД, которые опознаются только рядом с якорным словом. */
class ContextDetectionTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
@Test
void masksPassportInEveryNotation() {
assertHidden("Паспорт 4509 123456 выдан давно", "4509 123456");
assertHidden("паспорт гражданина РФ 45 09 123456", "45 09 123456");
assertHidden("ПАСПОРТ 4509123456", "4509123456");
assertHidden("Серия 4509 номер 123456", "4509");
assertHidden("серии 45 09 № 123456", "123456");
}
@Test
void masksPassportSeriesAndNumberSplitByWords() {
String masked = mask("Документ: серия 4509 номер 123456, выдан отделом");
assertTrue(masked.contains("серия "), masked);
assertTrue(masked.contains("номер "), masked);
assertFalse(masked.contains("4509"), masked);
assertFalse(masked.contains("123456"), masked);
}
@Test
void masksDepartmentCode() {
assertHidden("Код подразделения 770-001", "770-001");
assertHidden("к/п 770001", "770001");
}
@Test
void masksIssuingAuthorityButNotTheDateAfterIt() {
String masked = mask("Паспорт выдан ОУФМС России по г. Москве 12.05.2015");
assertFalse(masked.contains("ОУФМС"), masked);
assertFalse(masked.contains("12.05.2015"), masked);
assertTrue(masked.contains("**.**.****"), "дата маскируется отдельно от органа выдачи: " + masked);
}
@Test
void masksDriverLicense() {
assertHidden("Водительское удостоверение 9902 123456", "9902 123456");
assertHidden("в/у 99 02 123456", "99 02 123456");
}
@Test
void masksCitizenship() {
assertHidden("Гражданство: РФ", "РФ");
assertHidden("гражданство Республики Беларусь", "Беларусь");
assertHidden("Гражданин России обратился", "России");
}
@Test
void masksBirthPlace() {
// Место рождения — тип из requireCompanion: без другого ПД рядом не маскируется
// («Нижний Новгород» в рассказе о городе не должен теряться), поэтому в тесте
// на распознавание якоря рядом добавлен телефон.
assertHidden("Место рождения: город Тверь, проживает в Москве, тел. +7 916 123-45-67", "город Тверь");
assertHidden("Родился в Нижнем Новгороде, тел. +7 916 123-45-67", "Нижнем Новгороде");
}
@Test
void doesNotMaskBirthPlaceWithoutAnyOtherPersonalData() {
String text = "Экскурсия в Нижний Новгород перенесена на май";
assertEquals(text, mask(text), "место рождения без другого ПД рядом не маскируется");
}
@Test
void masksBirthPlaceWhenOtherPersonalDataIsAlsoPresent() {
assertHidden("Место рождения: город Тверь, ИНН 770301234550", "город Тверь");
}
@Test
void doesNotMaskCountryWithoutAnyOtherPersonalData() {
String text = "Цены на нефть выросли в Казахстане в этом квартале";
assertEquals(text, mask(text), "страна без другого ПД рядом не маскируется");
}
@Test
void masksCountryWhenOtherPersonalDataIsAlsoPresent() {
assertHidden("Страна проживания Казахстан, ИНН 770301234550", "Казахстан");
}
@Test
void masksCardholderName() {
assertHidden("Держатель карты IVAN PETROV", "IVAN PETROV");
assertHidden("cardholder: PETR SIDOROV", "PETR SIDOROV");
}
@Test
void masksSecurityCodeAndPinCompletely() {
String masked = mask("Карта 4111 1111 1111 1111, CVV 123, пин-код 4321");
assertFalse(masked.contains("123,"), masked);
assertFalse(masked.contains("4321"), masked);
assertTrue(masked.contains("***"), masked);
}
@Test
void doesNotMaskPinWithoutAnyOtherPersonalData() {
String text = "Пин-код 1234 введён неверно";
assertEquals(text, mask(text), "одиночный пин-код персональными данными не является");
}
@Test
void masksPinWhenCardNumberIsAlsoPresent() {
assertHidden("Пин-код 1234 от карты 4111 1111 1111 1111", "1234 от");
}
@Test
void anchorWordsAreCaseInsensitive() {
assertHidden("ПАСПОРТ СЕРИЯ 4509 НОМЕР 123456", "123456");
assertHidden("гРаЖдАнСтВо РФ, паспорт 4509 123456", "4509 123456");
}
@Test
void complexSentenceKeepsSurroundingWords() {
String original = "Клиент, паспорт 4509 123456 выдан ОУФМС по г. Москве, "
+ "код подразделения 770-001, ИНН 770301234550, телефон +7 916 123-45-67";
String masked = mask(original);
assertTrue(masked.startsWith("Клиент, паспорт "), masked);
assertTrue(masked.contains("код подразделения"), masked);
assertTrue(masked.contains("телефон"), masked);
assertFalse(masked.contains("4509 123456"), masked);
assertFalse(masked.contains("770301234550"), masked);
}
@Test
void unmaskingRestoresComplexSentence() {
String original = "Паспорт 4509 123456, выдан ОУФМС России по г. Москве, "
+ "код подразделения 770-001, гражданство РФ, CVV 123, карта 4111 1111 1111 1111";
String id = "complex-1";
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
assertFalse(masked.contains("4509 123456"), masked);
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
}
}
@@ -0,0 +1,143 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Даты во всех вариантах записи и составляющие адреса. */
class DateAndAddressTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
@Test
void masksBirthDateInAnyPartOrder() {
assertHidden("Дата рождения 12.05.1985", "12.05.1985");
assertHidden("дата рождения: 05/12/1985", "05/12/1985");
assertHidden("Дата рождения 1985-12-05", "1985-12-05");
assertHidden("Родился 12-05-1985", "12-05-1985");
assertHidden("12.05.1985 г.р. — данные клиента", "12.05.1985");
}
@Test
void masksBirthDateWrittenWithWords() {
assertHidden("Дата рождения: 12 мая 1985 года", "12 мая 1985");
assertHidden("Дата рождения двенадцатого мая тысяча девятьсот восемьдесят пятого года",
"двенадцатого мая");
assertHidden("Дата рождения: двадцать первого августа 1990 года", "двадцать первого августа");
}
@Test
void keepsSeparatorsInMaskedDate() {
String masked = mask("Дата рождения 12.05.1985");
assertTrue(masked.endsWith("**.**.****"), masked);
}
@Test
void masksPassportIssueDate() {
assertHidden("Паспорт 4509 123456, дата выдачи 12.05.2015", "12.05.2015");
}
@Test
void doesNotMaskDateWithoutAnyOtherPersonalData() {
String text = "Встреча перенесена на 12.05.2025, подтвердите";
assertEquals(text, mask(text), "дата сама по себе персональными данными не является");
}
@Test
void masksBareDateWhenOtherPersonalDataIsPresent() {
assertHidden("Паспорт 4509 123456 оформлен 12.05.2015", "12.05.2015");
}
@Test
void doesNotTreatVersionOrAddressLikeNumbersAsDate() {
String text = "Сервер 192.168.1 отвечает, сборка 1.2.3 развёрнута";
assertEquals(text, mask(text));
}
@Test
void masksAddressComponentsSeparately() {
String masked = mask("Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15");
assertFalse(masked.contains("125009"), masked);
assertFalse(masked.contains("Москва"), masked);
assertFalse(masked.contains("Тверская"), masked);
assertTrue(masked.contains("г. "), "указатели вида «г.», «ул.» остаются: " + masked);
assertTrue(masked.contains("ул. "), masked);
}
@Test
void streetNameDoesNotSwallowTheRestOfTheSentence() {
String masked = mask("Адрес клиента: ул. Сосновая перекрыта из-за ремонта");
assertTrue(masked.contains("перекрыта из-за ремонта"),
"название улицы это одно-три слова, а не остаток предложения: " + masked);
assertFalse(masked.contains("Сосновая"), masked);
}
@Test
void doesNotMaskStreetMentionedOutsideAnAddress() {
assertEquals("Проспект Мира перекрыт до вечера", mask("Проспект Мира перекрыт до вечера"));
assertEquals("Улица Весенняя названа в честь праздника",
mask("Улица Весенняя названа в честь праздника"));
}
@Test
void masksMultiWordStreetName() {
String masked = mask("Адрес: г. Москва, ул. Малая Никитская, д. 4");
assertFalse(masked.contains("Малая Никитская"), masked);
}
@Test
void masksIndexByAnchorWord() {
assertHidden("Индекс 125009 для доставки клиенту Иванову, паспорт 4509 123456", "125009");
}
@Test
void doesNotMaskBankBranchAddress() {
String text = "Отделение банка на улице Тверская, дом 7 работает до 20:00";
assertEquals(text, mask(text), "адрес отделения банка персональными данными не является");
}
@Test
void doesNotMaskOfficeAddress() {
String text = "Дополнительный офис, г. Москва, ул. Арбат, д. 1";
assertEquals(text, mask(text));
}
@Test
void addressTypesAreConfigurableSeparately() {
SystemPolicy onlyCity = SystemPolicy.forTypes(RuleRegistry.ADDRESS_CITY);
String masked = pipeline.process("г. Москва, ул. Тверская, д. 7", "addr-1", onlyCity);
assertFalse(masked.contains("Москва"), masked);
assertTrue(masked.contains("Тверская"), "улица этой системой не маскируется: " + masked);
}
@Test
void unmaskingRestoresTextWithDateAndAddress() {
String original = "Иванов, дата рождения 12.05.1985, адрес: 125009, г. Москва, "
+ "ул. Тверская, д. 7, кв. 15, паспорт 4509 123456";
String id = "date-addr-1";
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
assertFalse(masked.contains("12.05.1985"), masked);
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
}
}
+131
View File
@@ -0,0 +1,131 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** ФИО и защита от ложных срабатываний. */
class FioTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
private void assertUnchanged(String text) {
assertEquals(text, mask(text), "ложное срабатывание");
}
@Test
void masksFullNameAsInitials() {
assertEquals("Клиент И. И. И. обратился", mask("Клиент Иванов Иван Иванович обратился"));
}
@Test
void masksNameAndPatronymicWithoutSurname() {
assertHidden("Пригласите Ивана Сергеевича на встречу", "Ивана Сергеевича");
}
@Test
void masksFemalePatronymic() {
assertHidden("Петрова Анна Ивановна подписала", "Петрова Анна Ивановна");
assertHidden("Мария Никитична ждёт ответа", "Мария Никитична");
}
@Test
void masksSurnameWithInitialsInBothOrders() {
assertHidden("Заявление от Иванов И.И. принято", "Иванов И.И.");
assertHidden("Подписал И.И. Иванов", "И.И. Иванов");
}
@Test
void masksSurnameNextToKnownGivenName() {
assertHidden("Петров Сергей подтвердил заявку", "Петров Сергей");
assertHidden("Сергей Петров подтвердил заявку", "Сергей Петров");
assertHidden("Обращение Ольги Ковалёвой рассмотрено", "Ольги Ковалёвой");
}
@Test
void masksLowercaseNameAfterExplicitAnchor() {
assertHidden("ФИО: иванов иван иванович", "иванов иван иванович");
assertHidden("Карта оформлена на имя петров сергей", "петров сергей");
}
@Test
void masksNameAfterRoleAnchor() {
assertHidden("Клиент Петров Сергей, заявка одобрена", "Петров Сергей");
assertHidden("Плательщик Ковалёва подтвердила перевод", "Ковалёва");
}
@Test
void doesNotMaskWellKnownPerson() {
assertUnchanged("Напиши стихотворение в духе Александра Пушкина про осень");
assertUnchanged("Сравни Толстого и Достоевского как прозаиков");
assertUnchanged("Когда Гагарин полетел в космос");
}
@Test
void masksWellKnownSurnameWhenOtherPersonalDataIsPresent() {
assertHidden("Клиент Александр Пушкин, паспорт 4509 123456", "Александр Пушкин");
}
@Test
void doesNotMaskPlaceNamesThatLookLikeSurnames() {
assertUnchanged("Московский Кремль открыт для посещения");
assertUnchanged("Экскурсия в Нижний Новгород перенесена");
assertUnchanged("Смоленская площадь закрыта на ремонт");
}
@Test
void doesNotMaskOrdinaryCapitalisedWords() {
assertUnchanged("Банк Открытие подтвердил лимит");
assertUnchanged("В Понедельник Отдел Согласует Договор");
}
@Test
void identificationIgnoresCase() {
assertHidden("ИВАНОВ ИВАН ИВАНОВИЧ", "ИВАНОВ ИВАН ИВАНОВИЧ");
assertHidden("фио: петрова анна ивановна", "петрова анна ивановна");
}
@Test
void unmaskingRestoresNames() {
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456, "
+ "дата рождения 12.05.1985, телефон +7 916 123-45-67";
String id = "fio-1";
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
assertFalse(masked.contains("Иванов Иван Иванович"), masked);
assertTrue(masked.contains("И. И. И."), masked);
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
}
@Test
void namesStayFastOnLargeText() {
String block = "Клиент Иванов Иван Иванович, паспорт 4509 123456, город Москва. ";
String large = block.repeat(4000);
long started = System.nanoTime();
String masked = pipeline.process(large, "fio-large", SystemPolicy.DEFAULT);
long millis = (System.nanoTime() - started) / 1_000_000;
assertFalse(masked.contains("Иванов Иван Иванович"));
assertTrue(millis < 1000, "обработка заняла " + millis + " мс");
}
}
@@ -0,0 +1,70 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/** Документы, удостоверяющие личность, помимо паспорта РФ. */
class IdentityDocumentTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
private void assertHidden(String text, String secret) {
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
private void assertMasked(String text, String payloadId, String expected) {
assertEquals(expected, pipeline.process(text, payloadId, SystemPolicy.DEFAULT));
}
@Test
void masksForeignPassport() {
assertHidden("Загранпаспорт 75 1234567 действителен до 2030 года", "75 1234567");
}
@Test
void masksMilitaryId() {
assertHidden("Военный билет АБ 1234567 предъявлен", "АБ 1234567");
}
@Test
void masksBirthCertificate() {
assertHidden("Свидетельство о рождении II-МЮ № 123456", "II-МЮ № 123456");
}
@Test
void masksMedicalPolicy() {
assertHidden("Полис ОМС 1234567890123456 оформлен", "1234567890123456");
}
/**
* У загранпаспорта, военного билета и свидетельства о рождении серия короткая —
* две цифры или две буквы. Открой маска первые два знака, серия была бы видна
* целиком, поэтому у этих документов открыты только последние знаки номера.
*/
@Test
void hidesShortDocumentSeriesCompletely() {
assertMasked("Загранпаспорт 75 1234567", "fp-1", "Загранпаспорт ** *****67");
assertMasked("Военный билет АБ 1234567", "mil-1", "Военный билет ** *****67");
assertMasked("Свидетельство о рождении II-МЮ № 123456", "bc-1",
"Свидетельство о рождении **-** № ****56");
}
/** У паспорта РФ и водительского удостоверения серия из четырёх знаков — открыта половина. */
@Test
void keepsHalfOfFourCharacterSeries() {
assertMasked("Паспорт 4509 123456", "rf-1", "Паспорт 45** ****56");
assertMasked("Водительское удостоверение 9902 123456", "dl-1",
"Водительское удостоверение 99** ****56");
}
}
+147
View File
@@ -0,0 +1,147 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.Span;
import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.Random;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Качество и скорость на большом тексте — не повторе одного и того же
* предложения, а перемешанных строках из {@code benchmark-generated.txt}
* (все типы ПД вперемешку с чистым текстом), растянутых до объёма из ТЗ
* (около 100 000 токенов, ~400 КБ по оценке из README).
*
* <p>Раздутый повтором одной строки текст проверяет только то, что цикл не
* падает на объёме: под маской всегда один и тот же тип, а остальные правила
* не задействуются вовсе. Здесь размер и разнообразие проверяются вместе.
*/
class LargeTextTest {
private static final String ENGINE = System.getProperty("bench.engine", "rubert");
private static final String MODEL_PATH = System.getProperty("bench.model", "models/rubert-ner");
/** Целевой объём: README оценивает 100 000 токенов как ~400 КБ текста. */
private static final int TARGET_CHARS = 400_000;
/**
* Перемешивает исходные строки (фиксированный seed — детерминированный
* тест) и склеивает их через перенос строки, пока не наберётся целевой
* объём. Смещения золотых фрагментов пересчитываются под общий текст.
*/
private static BenchmarkFixtures.Sample buildLargeText(int targetChars, long seed) {
List<BenchmarkFixtures.Sample> pool = new ArrayList<>(BenchmarkFixtures.load("/benchmark-generated.txt"));
Random random = new Random(seed);
StringBuilder text = new StringBuilder(targetChars + 1024);
List<Span> gold = new ArrayList<>();
while (text.length() < targetChars) {
Collections.shuffle(pool, random);
for (BenchmarkFixtures.Sample sample : pool) {
int offset = text.length();
text.append(sample.text()).append('\n');
for (Span span : sample.gold()) {
gold.add(new Span(span.start() + offset, span.end() + offset, span.type(), 0));
}
if (text.length() >= targetChars) {
break;
}
}
}
return new BenchmarkFixtures.Sample(text.toString(), gold);
}
/**
* Маскирование и обратное преобразование на большом тексте дают
* побайтово тот же результат, что и исходный текст — при объёме на
* порядок больше, чем в остальных тестах, и с разнородным содержимым,
* а не одним повторяющимся предложением.
*/
@Test
void roundTripOnLargeMixedText() {
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 1);
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
new PayloadStore(large.text().length() * 2L, 30));
long maskStarted = System.nanoTime();
String masked = pipeline.process(large.text(), "large-mixed-1", SystemPolicy.DEFAULT);
long maskMillis = (System.nanoTime() - maskStarted) / 1_000_000;
long unmaskStarted = System.nanoTime();
String restored = pipeline.process(masked, "large-mixed-1", SystemPolicy.DEFAULT);
long unmaskMillis = (System.nanoTime() - unmaskStarted) / 1_000_000;
assertEquals(large.text(), restored, "демаскирование не восстановило исходный текст");
assertTrue(maskMillis < 5000, "маскирование " + large.text().length() + " знаков заняло " + maskMillis + " мс");
assertTrue(unmaskMillis < 1000, "демаскирование заняло " + unmaskMillis + " мс");
System.out.printf("%nБольшой текст: %d знаков, маскирование %d мс, демаскирование %d мс%n",
large.text().length(), maskMillis, unmaskMillis);
}
/**
* Полнота детекции не должна проседать на объёме: каждый золотой
* фрагмент из перемешанных строк обязан быть найден в общем потоке
* текста, а не только когда он единственный в маленькой строке.
*/
@Test
void recallHoldsAtScale() {
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 2);
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1L, 30));
List<Span> found = pipeline.findPersonalData(large.text(), SystemPolicy.DEFAULT);
int hit = 0;
for (Span gold : large.gold()) {
if (found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold))) {
hit++;
}
}
double recall = large.gold().isEmpty() ? 1.0 : (double) hit / large.gold().size();
System.out.printf("%nПолнота на большом тексте: %d из %d (%.3f)%n", hit, large.gold().size(), recall);
assertTrue(recall >= 0.85,
String.format("полнота на большом тексте упала до %.3f (%d/%d)", recall, hit, large.gold().size()));
}
/**
* Вторая ступень ограничена числом кандидатов на запрос
* ({@code pdguard.ner.max-candidates}), поэтому объём текста не должен
* превращать её в квадратичную нагрузку — проверяем на том же большом
* тексте, что и остальные тесты, а не на маленьком образце.
*/
@Test
void nameCascadeStaysBoundedOnLargeText() {
Path model = Path.of(MODEL_PATH);
if (!Files.isReadable(model)) {
System.out.println("Модель " + model.toAbsolutePath() + " не собрана, пропускаю");
return;
}
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 3);
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
new PayloadStore(large.text().length() * 2L, 30),
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4));
long started = System.nanoTime();
pipeline.process(large.text(), "large-cascade-1", SystemPolicy.DEFAULT);
long millis = (System.nanoTime() - started) / 1_000_000;
System.out.printf("%nБольшой текст со второй ступенью: %d знаков за %d мс%n",
large.text().length(), millis);
assertTrue(millis < 5000, "со второй ступенью обработка заняла " + millis + " мс");
}
}
@@ -0,0 +1,86 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Validators;
import ru.pdguard.mask.MaskMode;
import ru.pdguard.mask.Masker;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Виды замены: звёздочки, токены, правдоподобные значения. */
class MaskModeTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
private SystemPolicy policy(MaskMode mode) {
return new SystemPolicy(SystemPolicy.DEFAULT_NAME, true, true, mode,
Set.of(SystemPolicy.ALL), SystemPolicy.DEFAULT.requireCompanion(), null);
}
private String mask(MaskMode mode, String text) {
return pipeline.process(text, UUID.randomUUID().toString(), policy(mode));
}
@Test
void tokenModeNumbersEachType() {
String masked = mask(MaskMode.TOKEN, "Клиент Иванов Иван Иванович, почта ivan@mail.ru");
assertTrue(masked.contains("[FIO_1]"), masked);
assertTrue(masked.contains("[EMAIL_1]"), masked);
}
@Test
void sameValueGetsSameTokenWithinRequest() {
String masked = mask(MaskMode.TOKEN, "ivan@mail.ru и ещё раз ivan@mail.ru, а также petr@mail.ru");
assertEquals(2, count(masked, "[EMAIL_1]"), masked);
assertEquals(1, count(masked, "[EMAIL_2]"), masked);
}
@Test
void syntheticModeProducesPlausibleValues() {
String masked = mask(MaskMode.SYNTHETIC, "Карта 4111 1111 1111 1111 клиента Иванова Ивана");
assertFalse(masked.contains("4111 1111 1111 1111"), masked);
assertFalse(masked.contains("*"), "подстановка должна выглядеть настоящей: " + masked);
Matcher card = Pattern.compile("\\d{4} \\d{4} \\d{4} \\d{4}").matcher(masked);
assertTrue(card.find(), masked);
assertTrue(Validators.luhn(card.group()), "подставленный номер карты обязан проходить проверку Луна");
}
@Test
void syntheticValuesAreStable() {
String text = "Почта ivan@mail.ru, паспорт 4509 123456";
assertEquals(mask(MaskMode.SYNTHETIC, text), mask(MaskMode.SYNTHETIC, text));
}
@Test
void unmaskingWorksInEveryMode() {
String original = "Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111, почта ivan@mail.ru";
for (MaskMode mode : MaskMode.values()) {
String id = "mode-" + mode;
String masked = pipeline.process(original, id, policy(mode));
assertFalse(masked.contains("Иванов Иван Иванович"), mode + ": " + masked);
assertEquals(original, pipeline.process(masked, id, policy(mode)), mode.name());
}
}
private static int count(String text, String fragment) {
int n = 0;
for (int i = text.indexOf(fragment); i >= 0; i = text.indexOf(fragment, i + fragment.length())) {
n++;
}
return n;
}
}
@@ -0,0 +1,59 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/** Вторая ступень не должна вредить первой. */
class NameCascadeTest {
private static final String TEXT = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
private String mask(NameCascade cascade, String payloadId) {
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
new PayloadStore(1_000_000L, 30), cascade);
return pipeline.process(TEXT, payloadId, SystemPolicy.DEFAULT);
}
@Test
void withoutModelTheStageIsOff() {
NameCascade cascade = NameCascade.disabled();
assertFalse(cascade.enabled());
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "off-1"));
}
@Test
void missingModelFileDoesNotBreakMasking(@TempDir Path dir) {
NameCascade cascade = new NameCascade("rubert", Optional.of(dir.resolve("нет-такого-каталога").toString()), 16, 4);
assertFalse(cascade.enabled(), "отсутствующая модель должна выключать ступень");
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "missing-1"));
}
@Test
void brokenModelFileDoesNotBreakMasking(@TempDir Path dir) throws IOException {
Path broken = dir.resolve("испорченная-модель");
Files.createDirectories(broken);
for (String name : new String[]{"model_int8.onnx", "vocab.txt", "config.json"}) {
Files.writeString(broken.resolve(name), "это не модель", StandardCharsets.UTF_8);
}
NameCascade cascade = new NameCascade("rubert", Optional.of(broken.toString()), 16, 4);
assertFalse(cascade.enabled(), "испорченная модель должна выключать ступень");
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "broken-1"),
"маскирование по правилам обязано работать и без второй ступени");
}
}
@@ -0,0 +1,92 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/**
* Имя в названии организации или объекта на карте персональными данными не является.
* Решает слово перед именем, а не само имя: однофамилец защиту не теряет.
*/
class OrganisationNamesTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
@Test
void keepsNamesInsideInstitutionNames() {
for (String text : new String[]{
"Институт Мечникова принимает по записи",
"Музей Верещагина работает по будням",
"Театр Станиславского открыл сезон",
"Библиотека Некрасова закрыта на ремонт",
"Премия имени Ломоносова вручена в декабре",
"Больница Боткина приняла пациентов",
"Стадион Яшина отремонтирован"}) {
assertEquals(text, mask(text), "имя в названии учреждения маскировать не нужно");
}
}
@Test
void keepsNamesInsidePlaceNames() {
for (String text : new String[]{
"Улица Королёва названа в честь конструктора",
"Проспект Вернадского перекрыт до вечера",
"Площадь Гагарина находится на юго-западе",
"Набережная Макарова уходит к заливу",
"Мост Кадырова разведут ночью"}) {
assertEquals(text, mask(text), "топоним маскировать не нужно");
}
}
@Test
void masksRealClientWithTheSameSurname() {
String masked = mask("Клиент Королёв Сергей Павлович, паспорт 4509 123456");
assertFalse(masked.contains("Королёв Сергей Павлович"),
"однофамилец объекта на карте остаётся под защитой: " + masked);
}
@Test
void markerOnlyCountsRightBeforeTheName() {
String masked = mask("Больница приняла Иванова Ивана Ивановича с жалобой");
assertFalse(masked.contains("Иванова Ивана Ивановича"),
"слово-маркер действует только вплотную перед именем: " + masked);
}
@Test
void keepsRulerNames() {
for (String text : new String[]{
"Василий Тёмный правил недолго",
"Ярослав Мудрый составил свод законов",
"Екатерина Вторая издала указ",
"Алексей Тишайший принимал послов"}) {
assertEquals(text, mask(text), "имя правителя персональными данными не является");
}
}
@Test
void masksClientEvenIfNameLooksRegnal() {
String masked = mask("Клиент Василий Тёмный, паспорт 4509 123456");
assertFalse(masked.contains("Василий Тёмный"),
"рядом с паспортными данными это конкретный человек: " + masked);
}
@Test
void doesNotSuppressSoleTraderName() {
String masked = mask("ИП Пахомов Вениамин Николаевич, ИНН 502601234547");
assertFalse(masked.contains("Пахомов Вениамин Николаевич"),
"имя предпринимателя — это персональные данные: " + masked);
}
}
@@ -0,0 +1,73 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.core.PayloadStore;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Ограничения хранилища соответствий: объём, срок жизни и разделение по системам. */
class PayloadStoreTest {
private static final String SYSTEM = "crm";
@Test
void returnsWhatWasStored() {
PayloadStore store = new PayloadStore(1_000_000L, 30);
store.put(SYSTEM, "id", "исходный текст", "маска");
PayloadStore.Entry entry = store.byId(SYSTEM, "id");
assertNotNull(entry);
assertEquals("исходный текст", entry.original());
assertEquals("маска", entry.masked());
assertEquals("исходный текст", store.originalForMask(SYSTEM, "маска"));
}
@Test
void forgetsEntriesAfterTheirLifetime() {
PayloadStore store = new PayloadStore(1_000_000L, 0);
store.put(SYSTEM, "id", "исходный текст", "маска");
assertNull(store.byId(SYSTEM, "id"), "запись с истёкшим сроком жизни не должна отдаваться");
assertNull(store.originalForMask(SYSTEM, "маска"));
}
@Test
void evictsOldestWhenOverSizeLimit() {
PayloadStore store = new PayloadStore(100L, 30);
for (int i = 0; i < 50; i++) {
store.put(SYSTEM, "id" + i, "текст номер " + i, "маска номер " + i);
}
assertTrue(store.charsHeld() <= 100, "объём хранилища вышел за предел: " + store.charsHeld());
assertNull(store.byId(SYSTEM, "id0"), "самая старая запись должна быть вытеснена");
assertNotNull(store.byId(SYSTEM, "id49"), "последняя запись должна остаться");
}
@Test
void unknownKeysReturnNothing() {
PayloadStore store = new PayloadStore(1_000_000L, 30);
assertNull(store.byId(SYSTEM, "нет такого"));
assertNull(store.originalForMask(SYSTEM, "нет такой маски"));
}
/**
* Поиск по маске идёт только внутри своей системы. Маски детерминированы и
* низкоэнтропийны: без разделения чужую маску можно было бы подобрать и обменять
* на исходные данные другого потребителя.
*/
@Test
void oneSystemCannotReadAnotherSystemData() {
PayloadStore store = new PayloadStore(1_000_000L, 30);
store.put("crm", "общий-id", "Иванов Иван Иванович", "И. И. И.");
assertNull(store.originalForMask("analytics", "И. И. И."),
"чужую маску нельзя обменять на исходный текст");
assertNull(store.byId("analytics", "общий-id"),
"совпадение идентификатора у другой системы не даёт доступа");
assertEquals("Иванов Иван Иванович", store.originalForMask("crm", "И. И. И."),
"своя система свои данные по-прежнему получает");
}
}
@@ -0,0 +1,149 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Оценка эффективности детекции по каждому типу ПДН в отдельности.
*
* <p>Набор {@code benchmark-pdn-types.txt} содержит по несколько примеров каждого
* типа ПДН. Для каждого типа считается посимвольная точность, полнота и F1 —
* так видно, какие типы детектор находит надёжно, а какие пропускает или
* маскирует сверх меры.
*/
class PdnTypeEfficiencyTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30));
/** Накопитель посимвольных совпадений по одному типу. */
private static final class Score {
private int truePositive;
private int falsePositive;
private int falseNegative;
private int gold() {
return truePositive + falseNegative;
}
private double precision() {
int found = truePositive + falsePositive;
return found == 0 ? 1.0 : (double) truePositive / found;
}
private double recall() {
return gold() == 0 ? 1.0 : (double) truePositive / gold();
}
private double f1() {
double p = precision();
double r = recall();
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
}
}
@Test
void efficiencyByPdnType() {
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load("/benchmark-pdn-types.txt");
Map<String, Score> byType = new LinkedHashMap<>();
Map<String, List<String>> missed = new LinkedHashMap<>();
for (BenchmarkFixtures.Sample sample : samples) {
List<Span> found = pipeline.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
String[] goldChars = paint(sample.text().length(), sample.gold());
String[] foundChars = paint(sample.text().length(), found);
for (int i = 0; i < sample.text().length(); i++) {
account(byType, goldChars[i], foundChars[i]);
}
for (Span gold : sample.gold()) {
boolean hit = found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold));
if (!hit) {
missed.computeIfAbsent(gold.type(), t -> new ArrayList<>())
.add(sample.text().substring(gold.start(), gold.end()));
}
}
}
report(byType);
reportMissed(missed);
// Каждый тип должен быть найден с F1 не ниже 0.8 — иначе детектор
// пропускает или перемаскирует этот тип ПДН. Companion-типы (CVV, PIN,
// DATE) проверяются отдельно: они маскируются только рядом с другими ПД.
for (Map.Entry<String, Score> e : byType.entrySet()) {
if (isCompanion(e.getKey())) {
continue;
}
assertTrue(e.getValue().f1() >= 0.8,
String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1()));
}
}
private void reportMissed(Map<String, List<String>> missed) {
if (missed.isEmpty()) {
return;
}
StringBuilder out = new StringBuilder();
out.append("\n=== Не распознанные значения по типам ===\n");
missed.forEach((type, values) -> {
out.append(type).append(": ").append(String.join(" | ", values)).append('\n');
});
System.out.println(out);
}
private static boolean isCompanion(String type) {
return "CVV".equals(type) || "PIN".equals(type) || "DATE".equals(type);
}
/** Раскрашивает каждый знак текста типом ПД, который его покрывает. */
private static String[] paint(int length, List<Span> spans) {
String[] painted = new String[length];
for (Span span : spans) {
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
painted[i] = span.type();
}
}
return painted;
}
private static void account(Map<String, Score> byType, String gold, String found) {
if (gold != null) {
Score score = byType.computeIfAbsent(gold, t -> new Score());
if (gold.equals(found)) {
score.truePositive++;
} else {
score.falseNegative++;
}
}
if (found != null && !found.equals(gold)) {
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
}
}
private void report(Map<String, Score> byType) {
StringBuilder out = new StringBuilder(2048);
out.append("\n=== Эффективность детекции по типам ПДН ===\n\n");
out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
byType.entrySet().stream()
.sorted(Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
.forEach(e -> out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n",
e.getKey(), e.getValue().gold(), e.getValue().precision(),
e.getValue().recall(), e.getValue().f1())));
System.out.println(out);
}
}
@@ -0,0 +1,210 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Замер производительности: задержка одиночного обращения и пропускная
* способность под нагрузкой. Не тест качества — он в {@link BenchmarkTest}.
*
* <p>Прогон идёт на одних правилах (вторая ступень выключена), как в боевой
* сборке без модели. Перед замером пайплайн прогревается, чтобы JIT успел
* скомпилировать горячий путь, — иначе первые замеры покажут интерпретируемый
* код и занизят результат в разы.
*/
class PerformanceBenchmarkTest {
/** Типовой текст с ПД — как в реальном обращении. */
private static final String[] PAYLOADS = {
"Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67",
"Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru",
"Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111",
"Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ",
"Напиши краткое описание продукта для рассылки клиентам банка",
};
/**
* Тексты, где правила не находят ПД, но есть цепочки имён — их разбирает
* вторая ступень (модель). Нужны, чтобы честно измерить стоимость модели,
* а не правила, которые в типовых текстах уже всё покрыли.
*/
private static final String[] CASCADE_PAYLOADS = {
"Готье и Руссо пришли на встречу в офис",
"Дюма написал роман за несколько месяцев",
"Виктор Гюго был известным писателем",
"Оноре де Бальзак писал романы о жизни",
"Жан-Поль Сартр философ и писатель",
};
private static final int WARMUP = 20_000;
private static final int MEASURE = 50_000;
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30));
private void warmup() {
for (int i = 0; i < WARMUP; i++) {
String text = PAYLOADS[i % PAYLOADS.length];
String id = "warmup-" + i;
pipeline.process(text, id, SystemPolicy.DEFAULT);
}
}
/** Задержка маскирования и демаскирования типового обращения. */
@Test
void singleRequestLatency() {
warmup();
long[] maskNanos = new long[MEASURE];
long[] unmaskNanos = new long[MEASURE];
for (int i = 0; i < MEASURE; i++) {
String text = PAYLOADS[i % PAYLOADS.length];
String id = "lat-" + i;
long t0 = System.nanoTime();
String masked = pipeline.process(text, id, SystemPolicy.DEFAULT);
maskNanos[i] = System.nanoTime() - t0;
long t1 = System.nanoTime();
pipeline.process(masked, id, SystemPolicy.DEFAULT);
unmaskNanos[i] = System.nanoTime() - t1;
}
Arrays.sort(maskNanos);
Arrays.sort(unmaskNanos);
double maskUs = nanosToMicros(maskNanos);
double unmaskUs = nanosToMicros(unmaskNanos);
System.out.printf("%n=== Задержка одиночного обращения (правила, без модели) ===%n");
System.out.printf("Маскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
maskNanos[MEASURE / 2] / 1000.0, maskNanos[(int) (MEASURE * 0.95)] / 1000.0,
maskNanos[(int) (MEASURE * 0.99)] / 1000.0, maskUs);
System.out.printf("Демаскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
unmaskNanos[MEASURE / 2] / 1000.0, unmaskNanos[(int) (MEASURE * 0.95)] / 1000.0,
unmaskNanos[(int) (MEASURE * 0.99)] / 1000.0, unmaskUs);
// Целевая задержка из ТЗ — 200 мс; типовое обращение должно укладываться в миллисекунды.
assertTrue(maskNanos[(int) (MEASURE * 0.99)] < 5_000_000,
"p99 маскирования превысил 5 мс: " + maskNanos[(int) (MEASURE * 0.99)] / 1_000_000 + " мс");
}
/** Пропускная способность под нагрузкой: сколько обращений в секунду выдерживает пайплайн. */
@Test
void throughputUnderLoad() throws Exception {
warmup();
int threads = Math.max(4, Runtime.getRuntime().availableProcessors());
int perThread = 10_000;
ExecutorService pool = Executors.newFixedThreadPool(threads);
long started = System.nanoTime();
List<Future<Long>> futures = new ArrayList<>();
for (int t = 0; t < threads; t++) {
final int threadId = t;
futures.add(pool.submit((Callable<Long>) () -> {
long local = 0;
for (int i = 0; i < perThread; i++) {
String text = PAYLOADS[(threadId * 31 + i) % PAYLOADS.length];
String id = "load-" + threadId + "-" + i;
long t0 = System.nanoTime();
pipeline.process(text, id, SystemPolicy.DEFAULT);
local += System.nanoTime() - t0;
}
return local;
}));
}
long totalNanos = 0;
for (Future<Long> f : futures) {
totalNanos += f.get();
}
long wallNanos = System.nanoTime() - started;
pool.shutdown();
pool.awaitTermination(30, TimeUnit.SECONDS);
int requests = threads * perThread;
double rps = requests / (wallNanos / 1e9);
double avgUs = totalNanos / (double) requests / 1000.0;
System.out.printf("%n=== Пропускная способность (%d потоков, %d обращений) ===%n", threads, requests);
System.out.printf("RPS: %.0f обращений/с средняя задержка: %.1f мкс%n", rps, avgUs);
assertTrue(rps > 1000, "пропускная способность ниже 1000 RPS: " + rps);
}
private static double nanosToMicros(long[] nanos) {
long sum = 0;
for (long n : nanos) {
sum += n;
}
return sum / (double) nanos.length / 1000.0;
}
/** Задержка со второй ступенью (ruBERT). Модель должна быть собрана. */
@Test
void singleRequestLatencyWithNameCascade() {
Path model = Path.of("models/rubert-ner");
assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана");
MeterRegistry meters = new SimpleMeterRegistry();
Pipeline withCascade = new Pipeline(new RuleRegistry(), new Masker(),
new PayloadStore(10_000_000L, 30),
new NameCascade("rubert", model.toString(), 16, 4, meters));
// Прогрев второй ступени: модель инициализируется лениво, первые вызовы медленные.
for (int i = 0; i < 200; i++) {
String text = CASCADE_PAYLOADS[i % CASCADE_PAYLOADS.length];
withCascade.process(text, "cascade-warmup-" + i, SystemPolicy.DEFAULT);
}
long[] maskNanos = new long[2000];
for (int i = 0; i < 2000; i++) {
String text = CASCADE_PAYLOADS[i % CASCADE_PAYLOADS.length];
String id = "cascade-lat-" + i;
long t0 = System.nanoTime();
withCascade.process(text, id, SystemPolicy.DEFAULT);
maskNanos[i] = System.nanoTime() - t0;
}
Arrays.sort(maskNanos);
int n = maskNanos.length;
System.out.printf("%n=== Задержка одиночного обращения со второй ступенью (ruBERT) ===%n");
System.out.printf("Маскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
maskNanos[n / 2] / 1000.0, maskNanos[(int) (n * 0.95)] / 1000.0,
maskNanos[(int) (n * 0.99)] / 1000.0, nanosToMicros(maskNanos));
double engaged = meters.counter("pdguard.ner.requests", "outcome", "engaged").count();
double candidates = meters.counter("pdguard.ner.candidates").count();
System.out.printf("Обращений к модели: %.0f, кандидатов разобрано: %.0f%n", engaged, candidates);
// Целевая задержка из ТЗ — 200 мс; даже со второй ступенью типовое обращение
// должно укладываться в десятки миллисекунд.
assertTrue(maskNanos[(int) (n * 0.99)] < 200_000_000,
"p99 маскирования со второй ступенью превысил 200 мс: "
+ maskNanos[(int) (n * 0.99)] / 1_000_000 + " мс");
}
}
+138
View File
@@ -0,0 +1,138 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Проверки маскирования и обратного преобразования без подъёма HTTP-слоя. */
class PipelineTest {
private static final String VALID_CARD = "4111 1111 1111 1111";
private static final String VALID_INN_12 = "770301234550";
private static final String VALID_SNILS = "112-233-445 95";
private Pipeline pipeline() {
return new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
}
private String mask(Pipeline pipeline, String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
@Test
void masksCardNumber() {
String masked = mask(pipeline(), "Оплата картой " + VALID_CARD + " прошла");
assertFalse(masked.contains(VALID_CARD), "номер карты остался в тексте: " + masked);
assertTrue(masked.contains("41** **** **** **11"), masked);
assertTrue(masked.startsWith("Оплата картой "), "окружающий текст изменён: " + masked);
}
@Test
void keepsNumberThatFailsLuhn() {
String text = "Заказ 1234 5678 9012 3456 отгружен";
assertEquals(text, mask(pipeline(), text));
}
@Test
void masksEmailKeepingTopLevelDomain() {
String masked = mask(pipeline(), "Почта ivan.petrov@mail.ru для связи");
assertEquals("Почта i**********@m***.ru для связи", masked);
}
@Test
void masksPhoneInAnyNotation() {
Pipeline pipeline = pipeline();
for (String phone : new String[]{"+7 (916) 123-45-67", "89161234567", "8 916 123 45 67"}) {
String masked = mask(pipeline, "Телефон " + phone);
assertFalse(masked.contains(phone), "телефон остался в тексте: " + masked);
assertTrue(masked.endsWith("67"), masked);
}
}
@Test
void masksInnByContextAndByChecksum() {
Pipeline pipeline = pipeline();
assertFalse(mask(pipeline, "ИНН: " + VALID_INN_12).contains(VALID_INN_12));
assertFalse(mask(pipeline, "Реквизиты " + VALID_INN_12 + " проверены").contains(VALID_INN_12));
}
@Test
void masksSnils() {
String masked = mask(pipeline(), "СНИЛС " + VALID_SNILS);
assertFalse(masked.contains(VALID_SNILS), masked);
}
@Test
void unmaskingRestoresOriginalText() {
Pipeline pipeline = pipeline();
String original = "Карта " + VALID_CARD + ", почта ivan@mail.ru, телефон +7 916 123-45-67";
String id = "pair-1";
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
assertNotEquals(original, masked);
String restored = pipeline.process(masked, id, SystemPolicy.DEFAULT);
assertEquals(original, restored);
}
@Test
void retryReturnsSameMask() {
Pipeline pipeline = pipeline();
String original = "Карта " + VALID_CARD;
String id = "retry-1";
String first = pipeline.process(original, id, SystemPolicy.DEFAULT);
String second = pipeline.process(original, id, SystemPolicy.DEFAULT);
assertEquals(first, second);
}
@Test
void unmasksWhenPayloadIdIsUnknown() {
Pipeline pipeline = pipeline();
String original = "Почта ivan@mail.ru";
String masked = pipeline.process(original, "lost-id", SystemPolicy.DEFAULT);
assertEquals(original, pipeline.process(masked, "другой-идентификатор", SystemPolicy.DEFAULT));
}
@Test
void textWithoutPersonalDataIsUnchanged() {
String text = "Расскажи о погоде в Москве завтра";
assertEquals(text, mask(pipeline(), text));
}
@Test
void systemPolicyDisablesSelectedTypes() {
Pipeline pipeline = pipeline();
SystemPolicy onlyEmail = SystemPolicy.forTypes(RuleRegistry.EMAIL);
String masked = pipeline.process("Карта " + VALID_CARD + ", почта ivan@mail.ru", "policy-1", onlyEmail);
assertTrue(masked.contains(VALID_CARD), "карта не должна маскироваться этой системой: " + masked);
assertFalse(masked.contains("ivan@mail.ru"), masked);
}
@Test
void handlesLargeText() {
Pipeline pipeline = pipeline();
String block = "Клиент написал с адреса ivan@mail.ru и оплатил картой " + VALID_CARD + ". ";
String large = block.repeat(4000);
long started = System.nanoTime();
String masked = pipeline.process(large, "large-1", SystemPolicy.DEFAULT);
long millis = (System.nanoTime() - started) / 1_000_000;
assertFalse(masked.contains("ivan@mail.ru"));
assertEquals(large, pipeline.process(masked, "large-1", SystemPolicy.DEFAULT));
assertTrue(millis < 1000, "обработка крупного текста заняла " + millis + " мс");
}
}
@@ -0,0 +1,214 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.not;
/** Проверка контракта: форма запроса и ответа, пара «маскирование — демаскирование». */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProcessResourceTest {
@LocalServerPort
int port;
private String post(String payload, String payloadId) {
return given()
.port(port)
.contentType("application/json")
.body(Map.of("payload", payload, "payload_id", payloadId))
.when().post("/process")
.then().statusCode(200)
.extract().path("result");
}
@Test
void maskAndUnmaskPair() {
String original = "Клиент оставил почту ivan.petrov@mail.ru и телефон +7 916 123-45-67";
String id = "8a77d363c7c044b49b41d7b8a448243a";
String masked = post(original, id);
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
org.junit.jupiter.api.Assertions.assertEquals(original, post(masked, id));
}
@Test
void rejectsRequestWithoutRequiredFields() {
given()
.port(port)
.contentType("application/json")
.body(Map.of("payload", "текст"))
.when().post("/process")
.then().statusCode(400);
}
@Test
void healthProbeResponds() {
given().port(port).when().get("/health").then().statusCode(200).body(equalTo("OK"));
}
@Test
void disabledSystemIsRefused() {
given()
.port(port)
.contentType("application/json")
.header("X-System-Id", "disabled")
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "sys-1"))
.when().post("/process")
.then().statusCode(403);
}
@Test
void systemPolicySelectsMaskMode() {
String masked = given()
.port(port)
.contentType("application/json")
.header("X-System-Id", "crm")
.body(Map.of("payload", "Клиент Иванов Иван Иванович", "payload_id", "sys-2"))
.when().post("/process")
.then().statusCode(200)
.extract().path("result");
org.junit.jupiter.api.Assertions.assertTrue(masked.contains("[FIO_1]"), masked);
}
@Test
void unknownSystemFallsBackToDefaultPolicy() {
given()
.port(port)
.contentType("application/json")
.header("X-System-Id", "неизвестная-система")
.body(Map.of("payload", "почта ivan@mail.ru", "payload_id", "sys-3"))
.when().post("/process")
.then().statusCode(200)
.body("result", equalTo("почта i***@m***.ru"));
}
@Test
void metricsExposeLatencyAndTokenCounters() {
post("Клиент Иванов Иван Иванович", "metrics-1");
String body = given().port(port).when().get("/actuator/prometheus").then().statusCode(200).extract().asString();
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_process_seconds"), "нет метрики задержки");
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_tokens_processed_total"), "нет метрики TPS");
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_pd_detected_total"), "нет метрики типов ПД");
}
/**
* Соответствия разделены по системам. Маски детерминированы и низкоэнтропийны,
* поэтому без разделения, прислав чужую маску, можно было бы получить исходные
* данные другого потребителя.
*/
@Test
void anotherSystemCannotExchangeMaskForOriginal() {
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
String id = "cross-system-1";
String masked = given()
.port(port)
.contentType("application/json")
.body(Map.of("payload", original, "payload_id", id))
.when().post("/process")
.then().statusCode(200)
.extract().path("result");
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
String byOther = given()
.port(port)
.contentType("application/json")
.header("X-System-Id", "other")
.body(Map.of("payload", masked, "payload_id", "совсем-другой-id"))
.when().post("/process")
.then().statusCode(200)
.extract().path("result");
org.junit.jupiter.api.Assertions.assertNotEquals(original, byOther,
"чужая система не должна получать исходный текст по маске");
String bySameSystem = given()
.port(port)
.contentType("application/json")
.body(Map.of("payload", masked, "payload_id", id))
.when().post("/process")
.then().statusCode(200)
.extract().path("result");
org.junit.jupiter.api.Assertions.assertEquals(original, bySameSystem,
"своя система по своему идентификатору исходный текст получает");
}
@Test
void systemWithKeyRequiresIt() {
given().port(port).contentType("application/json")
.header("X-System-Id", "guarded")
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-1"))
.when().post("/process").then().statusCode(403);
given().port(port).contentType("application/json")
.header("X-System-Id", "guarded")
.header("X-System-Key", "wrong-key")
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-2"))
.when().post("/process").then().statusCode(403);
given().port(port).contentType("application/json")
.header("X-System-Id", "guarded")
.header("X-System-Key", "s3cret-key-2026")
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-3"))
.when().post("/process").then().statusCode(200);
}
@Test
void systemWithoutKeyWorksWithoutIt() {
given().port(port).contentType("application/json")
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-4"))
.when().post("/process").then().statusCode(200);
}
@Test
void metricsCountSecondStageInvocations() {
post("Клиент Иванов Иван Иванович", "ner-metrics-1");
String body = given().port(port).when().get("/actuator/prometheus").then().statusCode(200).extract().asString();
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_ner_requests_total"),
"нет счётчика обращений ко второй ступени");
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_ner_candidates_total"),
"нет счётчика участков, отданных модели");
}
@Test
void metricsDoNotLeakPersonalData() {
post("Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111", "metrics-2");
String body = given().port(port).when().get("/actuator/prometheus").then().statusCode(200).extract().asString();
// Значения метрик — это числа, и цифры из ПД могут случайно совпасть с ними.
// Утечка возможна только через имена и метки, поэтому значения отбрасываем.
String namesAndLabels = body.lines()
.filter(line -> !line.startsWith("#"))
.map(line -> line.contains(" ") ? line.substring(0, line.lastIndexOf(' ')) : line)
.reduce("", (a, b) -> a + "\n" + b);
org.junit.jupiter.api.Assertions.assertFalse(namesAndLabels.contains("Иванов"), "ПД попали в метрики");
org.junit.jupiter.api.Assertions.assertFalse(namesAndLabels.contains("4111"), "ПД попали в метрики");
}
@Test
void adminShowsSystemsAndTypes() {
given().port(port).when().get("/admin/config").then().statusCode(200).body("crm.maskMode", equalTo("TOKEN"));
given().port(port).when().get("/admin/types").then().statusCode(200);
}
@Test
void textWithoutPersonalDataIsReturnedAsIs() {
given()
.port(port)
.contentType("application/json")
.body(Map.of("payload", "тестовая строка", "payload_id", "selfcheck-1"))
.when().post("/process")
.then().statusCode(200)
.body("result", equalTo("тестовая строка"))
.body("result", not(equalTo("")));
}
}
@@ -0,0 +1,88 @@
package ru.pdguard;
import io.restassured.path.json.JsonPath;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Демонстрационное плечо: потребитель → маскирование → LLM → демаскирование. */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProxyResourceTest {
@LocalServerPort
int port;
private static final String PROMPT =
"Составь письмо клиенту Иванову Ивану Ивановичу, паспорт 4509 123456, почта ivan@mail.ru";
private JsonPath proxy(String prompt) {
return given()
.port(port)
.contentType("application/json")
.body(Map.of("prompt", prompt))
.when().post("/proxy")
.then().statusCode(200)
.extract().jsonPath();
}
@Test
void personalDataDoesNotReachTheModel() {
JsonPath json = proxy(PROMPT);
String toModel = json.getString("prompt_masked");
assertFalse(toModel.contains("Иванову Ивану Ивановичу"), toModel);
assertFalse(toModel.contains("4509 123456"), toModel);
assertFalse(toModel.contains("ivan@mail.ru"), toModel);
}
@Test
void consumerGetsTheAnswerWithOriginalValues() {
JsonPath json = proxy(PROMPT);
String answer = json.getString("response");
assertTrue(answer.contains("Иванову Ивану Ивановичу"), answer);
assertTrue(answer.contains("4509 123456"), answer);
assertTrue(answer.contains("ivan@mail.ru"), answer);
}
@Test
void responseShowsTheWholeChain() {
JsonPath json = proxy(PROMPT);
assertTrue(json.getString("prompt_masked").contains("[FIO_1]"),
"в модель уходит обратимая подстановка: " + json.getString("prompt_masked"));
assertTrue(json.getString("llm_response_masked").contains("[FIO_1]"),
"ответ модели ещё содержит подстановки");
assertFalse(json.getString("response").contains("[FIO_1]"),
"потребителю подстановки не видны");
assertEquals("заглушка", json.getString("llm"));
assertFalse(json.getMap("replaced").isEmpty(), "таблица замен не должна быть пустой");
}
@Test
void textWithoutPersonalDataPassesThrough() {
JsonPath json = proxy("Объясни разницу между вкладом и накопительным счётом");
assertEquals("Объясни разницу между вкладом и накопительным счётом", json.getString("prompt_masked"));
}
@Test
void rejectsEmptyPrompt() {
given().port(port).contentType("application/json").body(Map.of("prompt", " "))
.when().post("/proxy").then().statusCode(400);
}
@Test
void disabledSystemIsRefused() {
given().port(port).contentType("application/json")
.header("X-System-Id", "disabled")
.body(Map.of("prompt", PROMPT))
.when().post("/proxy").then().statusCode(403);
}
}
@@ -0,0 +1,119 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/**
* Адрес не только в городе: правило {@code ADDRESS_CITY} расширено якорями
* на сёла, посёлки, деревни, хутора, станицы, аулы и аалы — раньше словарь
* ограничивался официальными городами (~1100), и «рп. Ильинское»/«с. Кукуево»
* из ТЗ не находились вообще, дело было не в качестве детекции, а в том, что
* искать было негде.
*/
class SettlementTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
@Test
void masksVillage() {
assertHidden("Клиент проживает в с. Кукуево постоянно", "Кукуево");
}
@Test
void masksWorkersSettlement() {
assertHidden("Регистрация по адресу: рп. Ильинское", "Ильинское");
}
@Test
void masksUrbanTypeSettlement() {
assertHidden("Доставка курьером в пгт. Энем", "Энем");
}
@Test
void masksHamlet() {
assertHidden("Дом находится в д. Аксеновка", "Аксеновка");
}
@Test
void masksFarmstead() {
assertHidden("Клиент родом из х. Прогресс", "Прогресс");
}
@Test
void masksCossackStanitsa() {
assertHidden("Прописан в ст-ца Гиагинская", "Гиагинская");
}
@Test
void masksAul() {
assertHidden("Живёт в аул Блечепсин", "Блечепсин");
}
@Test
void masksSloboda() {
assertHidden("Проживает в сл. Екатериновка", "Екатериновка");
}
@Test
void masksAal() {
assertHidden("Зарегистрирован в аал Сартыков", "Сартыков");
}
/** Словарь не подтверждает выдуманное название — якорь один в один, а маски нет. */
@Test
void doesNotMaskFictionalPlaceNameDespiteRealAnchor() {
String text = "Клиент проживает в с. Мнимогорск";
assertEquals(text, mask(text), "выдуманное название не должно проходить словарь");
}
/** Вето по организационному контексту работает и для новых типов НП, не только для городов. */
@Test
void doesNotMaskOrganisationAddressInVillage() {
String text = "Ближайшее отделение банка находится в с. Кукуево";
assertEquals(text, mask(text), "адрес отделения банка не является ПД клиента");
}
@Test
void anchorsAreCaseInsensitive() {
assertHidden("клиент проживает в С. КУКУЕВО", "КУКУЕВО");
}
/** Реалистичное предложение: населённый пункт, улица и дом вместе. */
@Test
void masksSettlementStreetAndHouseTogether() {
String masked = mask("Проживает по адресу: д. Аксеновка, ул. Садовая, д. 7");
assertFalse(masked.contains("Аксеновка"), masked);
assertFalse(masked.contains("Садовая"), masked);
assertFalse(masked.contains("д. 7"), masked);
}
/**
* «с.» перед числом — обычная запись страницы («с. 25»), а не населённого
* пункта. Якорь не должен на этом срабатывать: правило требует заглавную
* букву сразу после якоря, а не цифру.
*/
@Test
void pageReferenceIsNotMistakenForSettlement() {
String text = "См. с. 25 договора";
assertEquals(text, mask(text), "номер страницы не должен приниматься за населённый пункт");
}
}
@@ -0,0 +1,93 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/**
* Составные названия улиц в честь людей — тот же класс ложных срабатываний,
* что и «Богдана Хмельницкого» на реальных адресах Альфа-Банка: правило ФИО
* без ролевого слова ловит «имя + фамилия по словообразованию», а улица в
* честь исторической фигуры выглядит точно так же. Одиночная фамилия («улица
* Ленина») под это правило не подпадает вообще — ему нужны два слова, поэтому
* все примеры здесь двухсловные, реальные названия улиц.
*/
class StreetDenylistTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
private void assertUnmasked(String text) {
assertEquals(text, mask(text), "улица в честь исторической фигуры не должна маскироваться: " + text);
}
@Test
void doesNotMaskNikolaiOstrovskyStreet() {
assertUnmasked("Живу на улице Николая Островского уже десять лет");
}
@Test
void doesNotMaskAlexanderMatrosovStreet() {
assertUnmasked("Магазин находится на улице Александра Матросова");
}
@Test
void doesNotMaskValeryChkalovStreet() {
assertUnmasked("Заезжайте на улицу Валерия Чкалова");
}
@Test
void doesNotMaskVeraVoloshinaStreet() {
assertUnmasked("Новый дом построили на улице Веры Волошиной");
}
@Test
void doesNotMaskIvanSusaninStreet() {
assertUnmasked("Школа расположена на улице Ивана Сусанина");
}
@Test
void doesNotMaskSergeyKirovStreet() {
assertUnmasked("Парковка есть на улице Сергея Кирова");
}
@Test
void doesNotMaskGeorgiDimitrovStreet() {
assertUnmasked("Остановка на улице Георгия Димитрова");
}
/**
* Тот же принцип, что и у Пушкина: рядом с другим ПД денилист не
* применяется — если в тексте всё-таки есть настоящие персональные данные,
* совпадение с историческим именем их не прикрывает.
*/
@Test
void masksCommemorativeStreetNameWhenOtherPersonalDataIsPresent() {
String masked = mask("Живу на улице Николая Островского, тел. +7 916 123-45-67");
assertFalse(masked.contains("Николая Островского"), masked);
}
/** Контроль: обычное клиентское имя того же грамматического вида всё ещё маскируется. */
@Test
void stillMasksRealClientNameWithSimilarPattern() {
String masked = mask("Живёт на улице, зовут Николая Смирнова");
assertFalse(masked.contains("Николая Смирнова"), masked);
}
@Test
void doesNotMaskDmitryDonskoyStreet() {
assertUnmasked("Дом стоит на улице Дмитрия Донского");
}
}
@@ -0,0 +1,90 @@
package ru.pdguard;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.config.SystemsConfig;
import ru.pdguard.mask.MaskMode;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Чтение и горячая перезагрузка списка систем. */
class SystemsConfigTest {
private static final String CONTENT = """
{
"default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"] },
"crm": { "enabled": true, "demask": false, "maskMode": "TOKEN", "types": ["FIO"] },
"old": { "enabled": false }
}
""";
private SystemsConfig configAt(Path file) {
return new SystemsConfig(file.toString(), new ObjectMapper());
}
@Test
void readsPoliciesFromFile(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
SystemsConfig config = configAt(file);
SystemPolicy crm = config.policyFor("crm");
assertEquals(MaskMode.TOKEN, crm.maskMode());
assertFalse(crm.demask());
assertTrue(crm.allows("FIO"));
assertFalse(crm.allows("CARD"));
assertFalse(config.policyFor("old").enabled());
}
@Test
void unknownSystemGetsDefaultPolicy(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
SystemPolicy policy = configAt(file).policyFor("никому-не-известная");
assertTrue(policy.enabled());
assertTrue(policy.allows("CARD"));
}
@Test
void worksWithoutConfigFile(@TempDir Path dir) {
SystemsConfig config = configAt(dir.resolve("нет-такого-файла.json"));
assertEquals(SystemPolicy.DEFAULT, config.policyFor("любая"));
}
@Test
void picksUpChangesWithoutRestart(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
SystemsConfig config = configAt(file);
assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode());
Files.writeString(file, CONTENT.replace("\"TOKEN\"", "\"SYNTHETIC\""), StandardCharsets.UTF_8);
config.reload();
assertEquals(MaskMode.SYNTHETIC, config.policyFor("crm").maskMode());
}
@Test
void brokenFileKeepsPreviousSettings(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
SystemsConfig config = configAt(file);
Files.writeString(file, "{ это не json", StandardCharsets.UTF_8);
config.reload();
assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode(),
"сломанный файл не должен ронять работающий сервис");
}
}
@@ -0,0 +1,87 @@
package ru.pdguard.detect;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Денилист известных людей: встроенный список и дозагрузка сверху без пересборки. */
class NameDictionaryTest {
/** Возвращает состояние словаря к встроенному списку — не течёт в остальные тесты. */
@AfterEach
void resetToBundledList() {
NameDictionary.useExternalFile(Path.of("config/well-known.txt"));
}
@Test
void bundledListCoversClassicFigures() {
// Сравнение по началу слова покрывает обычную русскую флексию («Пушкин» →
// «Пушкина»), но не прилагательное склонение («Толстой» → «Толстого», где
// «-ой» меняется на «-ого» целиком, а не дописывается) — известное
// ограничение самого приёма, не завязанное на список имён.
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"));
assertTrue(NameDictionary.isWellKnown("Портрет Толстой"));
}
@Test
void bundledListCoversCurrentPublicFigures() {
assertTrue(NameDictionary.isWellKnown("Заявление Набиуллиной"));
assertTrue(NameDictionary.isWellKnown("Интервью Путина"));
}
@Test
void unknownSurnameIsNotWellKnown() {
assertFalse(NameDictionary.isWellKnown("Заявление Смирнова"));
}
/**
* Фамилии, в честь которых чаще всего называют улицы в России (Росреестр).
* Само по себе «улица Ленина» никогда не попало бы под ФИО — для этого
* правила нужны два слова, — но денилист должен покрывать и составные
* названия («Феликса Дзержинского»), и вариации написания («Будённый»/
* «Буденный»).
*/
@Test
void bundledListCoversCommemorativeStreetNames() {
assertTrue(NameDictionary.isWellKnown("улица Кирова"));
// «-ский» склоняется целиком («Дзержинский» → «Дзержинского»), для таких
// основа обрезается сразу до «ск» — см. Declension.
assertTrue(NameDictionary.isWellKnown("проспект Дзержинского"));
assertTrue(NameDictionary.isWellKnown("улица Донского"));
// «Будённый» — чистое прилагательное без «-ский» (как «Толстой»): та же
// известная граница приёма, родительный падеж («Будённого») им не ловится.
assertTrue(NameDictionary.isWellKnown("улица Будённый"));
assertTrue(NameDictionary.isWellKnown("улица Буденный"), "написание без «ё» тоже должно ловиться");
assertTrue(NameDictionary.isWellKnown("улица Жукова"));
assertTrue(NameDictionary.isWellKnown("улица Островского"));
}
@Test
void externalFileAddsNamesWithoutRebuild(@TempDir Path dir) throws IOException {
Path file = dir.resolve("well-known.txt");
Files.writeString(file, "Кастомов\n", StandardCharsets.UTF_8);
NameDictionary.useExternalFile(file);
assertTrue(NameDictionary.isWellKnown("Интервью Кастомова"),
"дописанное сверху имя должно распознаваться наравне со встроенными");
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"),
"встроенный список не должен теряться при дозагрузке");
}
@Test
void missingExternalFileFallsBackToBundledListOnly(@TempDir Path dir) {
NameDictionary.useExternalFile(dir.resolve("нет-такого-файла.txt"));
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"));
assertFalse(NameDictionary.isWellKnown("Заявление Смирнова"));
}
}
@@ -0,0 +1,62 @@
package ru.pdguard.detect;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Словарь населённых пунктов — не только города, но и сёла, посёлки, деревни, хутора. */
class ToponymDictionaryTest {
@Test
void recognisesNominativeCase() {
assertTrue(ToponymDictionary.isKnownSettlement("Москва"));
assertTrue(ToponymDictionary.isKnownSettlement("Казань"));
assertTrue(ToponymDictionary.isKnownSettlement("Санкт-Петербург"));
}
@Test
void recognisesInflectedForms() {
assertTrue(ToponymDictionary.isKnownSettlement("Москве"), "дательный падеж города на гласную");
assertTrue(ToponymDictionary.isKnownSettlement("Тамбове"), "предложный падеж города на согласную");
assertTrue(ToponymDictionary.isKnownSettlement("Казани"), "родительный падеж");
}
@Test
void recognisesCisCapitals() {
assertTrue(ToponymDictionary.isKnownSettlement("Минск"));
assertTrue(ToponymDictionary.isKnownSettlement("Алматы"));
}
@Test
void rejectsMadeUpWord() {
assertFalse(ToponymDictionary.isKnownSettlement("Ерунда"));
assertFalse(ToponymDictionary.isKnownSettlement("Бла-бла"));
}
@Test
void isCaseInsensitive() {
assertTrue(ToponymDictionary.isKnownSettlement("МОСКВА"));
assertTrue(ToponymDictionary.isKnownSettlement("москва"));
}
/**
* Из переписи 2020–2021, не из ручного списка городов: сёла, посёлки,
* деревни, хутора, станицы, аулы, аалы — ровно то, чего не было, пока
* словарь ограничивался официальными городами. Примеры из ТЗ («рп.
* Ильинское», «с. Кукуево») и по одному реальному названию на тип
* населённого пункта.
*/
@Test
void recognisesSettlementsFromCensusNotJustOfficialCities() {
assertTrue(ToponymDictionary.isKnownSettlement("Ильинское"), "рп. Ильинское — пример из ТЗ");
assertTrue(ToponymDictionary.isKnownSettlement("Кукуево"), "с. Кукуево — пример из ТЗ");
assertTrue(ToponymDictionary.isKnownSettlement("Прогресс"), "хутор");
assertTrue(ToponymDictionary.isKnownSettlement("Гиагинская"), "станица");
assertTrue(ToponymDictionary.isKnownSettlement("Блечепсин"), "аул");
assertTrue(ToponymDictionary.isKnownSettlement("Энем"), "посёлок городского типа");
assertTrue(ToponymDictionary.isKnownSettlement("Аксеновка"), "деревня");
assertTrue(ToponymDictionary.isKnownSettlement("Екатериновка"), "слобода");
assertTrue(ToponymDictionary.isKnownSettlement("Сартыков"), "аал (Хакасия)");
}
}
@@ -0,0 +1,69 @@
package ru.pdguard.detect;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Контрольные суммы ОГРН/ОГРНИП: первые 12/14 цифр по модулю 11/13, младший разряд остатка. */
class ValidatorsTest {
@Test
void validOgrnPassesChecksum() {
assertTrue(Validators.ogrn("1027700123450"));
assertTrue(Validators.ogrn("1025000678900"));
assertTrue(Validators.ogrn("1045002233440"));
}
@Test
void invalidOgrnChecksumFails() {
assertFalse(Validators.ogrn("1027700123451"), "последняя цифра изменена — сумма не сходится");
}
@Test
void ogrnWrongLengthFails() {
assertFalse(Validators.ogrn("102770012345"), "12 цифр — не хватает контрольной");
assertFalse(Validators.ogrn("10277001234500"), "14 цифр — лишняя");
}
/** Остаток от деления на 11 может быть 10 — тогда контрольная цифра 0, не 10. */
@Test
void ogrnRemainderTenMapsToZero() {
assertTrue(Validators.ogrn("1000000000000"));
}
@Test
void validOgrnipPassesChecksum() {
assertTrue(Validators.ogrnip("304500116000157"));
assertTrue(Validators.ogrnip("312500000000013"));
assertTrue(Validators.ogrnip("305500112233041"));
}
@Test
void invalidOgrnipChecksumFails() {
assertFalse(Validators.ogrnip("304500116000158"), "последняя цифра изменена — сумма не сходится");
}
@Test
void ogrnipWrongLengthFails() {
assertFalse(Validators.ogrnip("30450011600015"), "14 цифр — не хватает контрольной");
assertFalse(Validators.ogrnip("30450011600015700"), "18 цифр — лишние");
}
/** Остаток от деления на 13 может быть 10 — тогда контрольная цифра 0, не 10. */
@Test
void ogrnipRemainderTenMapsToZero() {
assertTrue(Validators.ogrnip("100000000000000"));
}
@Test
void nonDigitCharactersAreIgnored() {
assertTrue(Validators.ogrn("10-27700-123450"), "разделители в номере не мешают счёту цифр");
}
@Test
void emptyValueIsNotValid() {
assertFalse(Validators.ogrn(""));
assertFalse(Validators.ogrnip(""));
}
}
@@ -0,0 +1,981 @@
105037, Г. МОСКВА, ИЗМАЙЛОВСКИЙ ПРОЕЗД, Д. 8А
105043, Г. МОСКВА, УЛ. 9-Я ПАРКОВАЯ, Д. 23
105094, Г. МОСКВА, УЛ. БОЛЬШАЯ СЕМЕНОВСКАЯ, Д. 42, СТР. 4
105120, Г. МОСКВА, УЛ. ЗЕМЛЯНОЙ ВАЛ, Д. 41, СТР. 1
105120, РФ, Г. МОСКВА, УЛ. ЗЕМЛЯНОЙ ВАЛ, Д. 41, СТР. 1, ПОМ. 1 (12,54 КВ.М.)
105318, ГОРОД МОСКВА, УЛ. ВЕЛЬЯМИНОВСКАЯ, Д. 6
107006, Г. МОСКВА, УЛ. НИЖНЯЯ КРАСНОСЕЛЬСКАЯ, Д. 45/17
107014, Г. МОСКВА, СОКОЛЬНИЧЕСКАЯ ПЛОЩАДЬ, Д. 9, ПОМЕЩЕНИЕ 14Н
107014, Г. МОСКВА, СОКОЛЬНИЧЕСКАЯ ПЛОЩАДЬ, Д. 9, ПОМЕЩЕНИЕ 14Н, К. 1-009
107031, Г. МОСКВА, УЛ. НЕГЛИННАЯ, ДОМ 10
107066, Г. МОСКВА, УЛ. НИЖНЯЯ КРАСНОСЕЛЬСКАЯ, ДОМ 45/17
107078, Г. МОСКВА, УЛ. КАЛАНЧЕВСКАЯ, 17, СТР. 1
107078, Г. МОСКВА, УЛ. КАЛАНЧЕВСКАЯ, Д.27
107078, Г. МОСКВА, УЛ. МАШИ ПОРЫВАЕВОЙ, Д.7, СТР.Г
107497, Г. МОСКВА, УЛ. ИРКУТСКАЯ, Д. 5/6, СТР. 1
107497, Г. МОСКВА, УЛ. ИРКУТСКАЯ, ДОМ 17, СТР. 8
108801, Г. МОСКВА, СОСЕНСКОЕ ПОСЕЛЕНИЕ, П. КОММУНАРКА, УЛ. ЛИПОВЫЙ ПАРК, Д. 11, ПОМ. 19/1
108811, Г. МОСКВА, Г. МОСКОВСКИЙ, УЛ. НИКИТИНА, Д. 2
108811, Г. МОСКВА, ПОС. МОСКОВСКИЙ, КВ-Л №80, Д. 11А, СТР.1
108811, Г. МОСКВА, ПОСЕЛЕНИЕ МОСКОВСКИЙ, КВ-Л №34, ДВЛД. 2, СТР. 1
108811, Г. МОСКВА, Р-Н КОММУНАРКА, КВ-Л 80, Д.3, СТР.1
108842, ГОРОД МОСКВА, Г. ТРОИЦК, ПЛОЩАДЬ АКАДЕМИЧЕСКАЯ, Д. 5
108849, ГОРОД МОСКВА, УЛ. КОРНЕЯ ЧУКОВСКОГО, Д. 2, СТР. 1
109029, ГОРОД МОСКВА, ПР-Д МИХАЙЛОВСКИЙ, Д. 3, СТР. 3.
109044, Г. МОСКВА, 3-Й КРУТИЦКИЙ ПЕРЕУЛОК, Д. 13
109044, Г. МОСКВА, 3-Й КРУТИЦКИЙ ПЕРЕУЛОК, Д. 13, ПОМ. 1, ЧАСТЬ КОМНАТЫ №9
109145, Г. МОСКВА, ЛЕРМОНТОВСКИЙ ПРОСПЕКТ, Д. 10, КОРП. 1, ПОМ. 6
109145, Г. МОСКВА, ЛЕРМОНТОВСКИЙ ПРОСПЕКТ, Д. 10, КОРП. 1, ПОМ. IV А
109316, Г. МОСКВА, ПР-КТ ВОЛГОГРАДСКИЙ, Д.41, К.1.
109341, Г. МОСКВА, УЛ. ПЕРЕРВА, Д. 43, КОРП. 1
109386, Г. МОСКВА, УЛ. СОВХОЗНАЯ, Д. 39
109462, Г. МОСКВА, УЛ. МАРШАЛА ЧУЙКОВА, Д. 3
109462, Г. МОСКВА, УЛ. МАРШАЛА ЧУЙКОВА, Д. 3, ПОМ. 1-012
109469, ГОРОД МОСКВА, УЛ. МАРЬИНСКИЙ ПАРК, Д. 1
109544, Г. МОСКВА, БУЛЬВАР ЭНТУЗИАСТОВ, Д. 2
109652, Г. МОСКВА, УЛ. ЛЮБЛИНСКАЯ, Д. 165, КОРП. 1
109652, Г. МОСКВА, УЛ. ЛЮБЛИНСКАЯ, Д. 165, КОРП. 1, ЧАСТЬ КОМН. 2
111396, Г. МОСКВА, СВОБОДНЫЙ ПРОСПЕКТ, Д. 20А
111677, ГОРОД МОСКВА, УЛ. ПОКРОВСКАЯ, Д. 18, СТР. 1
113191, Г. МОСКВА, УЛ. БОЛЬШАЯ ТУЛЬСКАЯ, Д. 2
113452, Г. МОСКВА, БАЛАКЛАВСКИЙ ПРОСПЕКТ, ДОМ 26
115035, Г. МОСКВА, УЛ. БОЛЬШАЯ ОРДЫНКА, Д. 21, СТР. 2
115035, Г. МОСКВА, УЛ. БОЛЬШАЯ ОРДЫНКА, Д. 21, СТР. 2, ПОМ. 103
115054, Г. МОСКВА, УЛ. ВАЛОВАЯ, Д. 6
115054, Г. МОСКВА, УЛ. ЩИПОК, Д. 11, СТР. 1
115088, ГОРОД МОСКВА, УЛ. 2-Я МАШИНОСТРОЕНИЯ, Д.6
115142, Г. МОСКВА, УЛ. КОЛОМЕНСКАЯ, Д. 16
115184, ГОРОД МОСКВА, УЛ. МАЛАЯ ОРДЫНКА, ДОМ 44
115280, Г. МОСКВА, УЛ. АВТОЗАВОДСКАЯ, Д. 5
115280, РФ, Г. МОСКВА, УЛ. АВТОЗАВОДСКАЯ, Д. 5, ПОМ. 1, 2 (33,94 КВ.М.)
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 3, ЭТАЖ 11, ПОМ. 1108
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 3, ЭТАЖ 11, ПОМ. 1109
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 3, ЭТАЖ 11, ПОМ. 1117
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 3, ЭТАЖ 11, ПОМ. 1120
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 3, ЭТАЖ 11, ПОМ. 1121
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 3, ЭТАЖ 8, ПОМ. 818
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 3, ЭТАЖ 8, ПОМ. 819
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 3, ЭТАЖ 8, ПОМ. 837
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 3, ЭТАЖ 9, ПОМ. 916
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 3, ЭТАЖ 9, ПОМ. 917
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 6
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18. КОРП. 3, ЭТАЖ 8, ПОМ. 808
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18. КОРП. 3, ЭТАЖ 8, ПОМ. 810
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18. КОРП. 3, ЭТАЖ 8, ПОМ. 843
115432, ГОРОД МОСКВА, ПРОСП. АНДРОПОВА, Д. 18 КОРП. 3, ЭТАЖ 6, ПОМ. 630
115432, ГОРОД МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 3, ЭТАЖ 6, ПОМ. 607
115432, ГОРОД МОСКВА, ПРОСП. АНДРОПОВА, Д. 18. КОРП. 3, ЭТАЖ 6, ПОМ. 606
115432, ГОРОД МОСКВА, ПРОСП. АНДРОПОВА, Д. 18. КОРП. 3, ЭТАЖ 6, ПОМ. 611
115432, ГОРОД МОСКВА, ПРОСП. АНДРОПОВА, Д. 18. КОРП. 3, ЭТАЖ 6, ПОМ. 623
115432, ГОРОД МОСКВА, ПРОСП. АНДРОПОВА, Д. 18. КОРП. 3, ЭТАЖ 6, ПОМ. 714
115432, ГОРОД МОСКВА, ПРОСП. АНДРОПОВА, Д. 18. КОРП. 6, ЭТАЖ 9, ПОМ. 908
115432, ГОРОД МОСКВА, ПРОСП. АНДРОПОВА, Д. 18. КОРП. 6, ЭТАЖ 9, ПОМ. 915
115470, Г. МОСКВА, ПР-КТ АНДРОПОВА, Д. 21
115533, Г. МОСКВА, ПРОСПЕКТ АНДРОПОВА, ДОМ 22
115583, Г. МОСКВА, УЛ. ГЕНЕРАЛА БЕЛОВА, Д. 35
115583, РФ, Г. МОСКВА, УЛ. ГЕНЕРАЛА БЕЛОВА, Д. 35, ПОМ. 1 (12,64 КВ.М)
117042, Г. МОСКВА, УЛ. ВЕНЁВСКАЯ, Д. 6
117216, Г. МОСКВА, БУЛЬВАР ДОНСКОГО ДМИТРИЯ, Д. 11, ПОМ. № 14, 14А
117216, Г. МОСКВА, БУЛЬВАР ДОНСКОГО ДМИТРИЯ, Д. 11, ПОМ. № 9
117218, Г. МОСКВА, УЛ. ПРОФСОЮЗНАЯ, Д. 13/12
117218, Г. МОСКВА, УЛ. ПРОФСОЮЗНАЯ, Д. 13/12, ПОМ. 15
117485, Г. МОСКВА, УЛ. ПРОФСОЮЗНАЯ, ДОМ 104
117519, Г. МОСКВА, УЛ. КРАСНОГО МАЯКА, Д. 2Б
117534, Г. МОСКВА, ВАРШАВСКОЕ ШОССЕ Д. 150
117545, Г. МОСКВА, 1-ЫЙ ДОРОЖНЫЙ ПРОЕЗД, Д. 4, СТР. 1
117556, Г. МОСКВА, ВАРШАВСКОЕ ШОССЕ, ДОМ 76, КОРП. 1
117556, Г. МОСКВА, ВАРШАВСКОЕ ШОССЕ, ДОМ 76, КОРПУС 1
117556, ГОРОД МОСКВА, Ш. ВАРШАВСКОЕ, Д. 91А
119002, Г. МОСКВА, ПЕР. ДЕНЕЖНЫЙ, Д. 3, СТРОЕН. 1
119019, Г. МОСКВА, УЛ. АРБАТ, Д. 4, СТР. 1, ПОМ. 8А/1П
119034, Г. МОСКВА, КОМСОМОЛЬСКИЙ ПРОСПЕКТ, Д. 24, СТР. 1, ПОМЕЩЕНИЕ XIII, ЧАСТЬ КОМНАТЫ № 4
119034, Г. МОСКВА, КОМСОМОЛЬСКИЙ ПРОСПЕКТ, Д. 24, СТР. 1., ПОМЕЩЕНИЕ XII, КОМНАТЫ № 1,2,3,16,17,18
119121, Г. МОСКВА, ПЛОЩАДЬ СМОЛЕНСКАЯ-СЕННАЯ, Д.27, СТРОЕНИЕ 1
119121, РФ, Г. МОСКВА, ПЛ. СМОЛЕНСКАЯ-СЕННАЯ, Д. 27, СТР. 1, ЧАСТЬ ПОМ. 1 (12,65 КВ.М)
119180, Г. МОСКВА, УЛ. БОЛЬШАЯ ПОЛЯНКА, Д. 61, СТР. 2, ПОМ. 2/1
119192, Г. МОСКВА, ЛОМОНОСОВСКИЙ ПРОСПЕКТ, ДОМ 25, КОРП.1
119192, Г. МОСКВА, ЛОМОНОСОВСКИЙ ПРОСПЕКТ, ДОМ 25, КОРПУС 1
119421, Г. МОСКВА, ЛЕНИНСКИЙ ПРОСПЕКТ, Д. 99
119421, Г. МОСКВА, ЛЕНИНСКИЙ ПРОСПЕКТ, Д. 99, ПОМ. 22/4/1
119421, ГОРОД МОСКВА, ПР-КТ ЛЕНИНСКИЙ, Д. 107
121165, Г. МОСКВА, КУТУЗОВСКИЙ ПРОСПЕКТ, Д.30/32
121165, Г. МОСКВА, КУТУЗОВСКИЙ ПРОСПЕКТ, ДОМ 30/32, СТР. 1
121500, МОСКОВСКАЯ ОБЛАСТЬ, Г. ОДИНЦОВО, ТЕР. МКАД, КМ 63-Й, СТР. 1
121552, Г. МОСКВА, УЛ. ЯРЦЕВСКАЯ, Д. 27, КОРП. 1
121552, РФ, Г. МОСКВА, УЛ. ЯРЦЕВСКАЯ, Д. 27, КОРП. 1, ПОМ. 6 (13,92 КВ.М.)
121609, Г. МОСКВА, ОСЕННИЙ БУЛЬВАР, Д. 7, КОРП. 1, ПОМ. 1-001 - 1-004, 1-006 - 1-014
121609, Г. МОСКВА, ОСЕННИЙ БУЛЬВАР, Д. 7, КОРП. 1, ПОМ. 1-005
121609, Г. МОСКВА, РУБЛЁВСКОЕ ШОССЕ, Д. 64, К. 2
121609, Г. МОСКВА, РУБЛЕВСКОЕ ШОССЕ, ДОМ 74
121609, Г. МОСКВА, Ш. РУБЛЁВСКОЕ, Д. 74
123022, Г. МОСКВА, УЛ. ПРЕСНЕНСКИЙ ВАЛ, Д. 7, СТР. 1
123022, РФ, Г. МОСКВА, УЛ. ПРЕСНЕНСКИЙ ВАЛ, Д. 7, СТР. 1, ПОМ. 1 (21,71 КВ.М.)
123056, Г. МОСКВА, УЛ. БОЛЬШАЯ ГРУЗИНСКАЯ, Д. 57, СТР. 1
123060, ГОРОД МОСКВА, УЛИЦА НАРОДНОГО ОПОЛЧЕНИЯ, ДОМ 45
123104, Г. МОСКВА, УЛ. МАЛАЯ БРОННАЯ, Д. 15 Б, 1Н, 2Н, 3Н
123112, Г. МОСКВА, 1-Й КРАСНОГВАРДЕЙСКИЙ ПРОЕЗД, Д. 15
123112, Г. МОСКВА, НАБЕРЕЖНАЯ ПРЕСНЕНСКАЯ, Д. 2, ПОМЕЩ. 1Н/6
123154, Г. МОСКВА, ПРОСПЕКТ МАРШАЛА ЖУКОВА, ДОМ 49, КОРПУС 1
123290, Г. МОСКВА, 1-Й МАГИСТРАЛЬНЫЙ ПРОЕЗД, Д. 9
123290, Г. МОСКВА, УЛ. 2-Я МАГИСТРАЛЬНАЯ, Д. 18, СТР. 22
123557, Г. МОСКВА, БОЛЬШОЙ ТИШИНСКИЙ ПЕР., Д. 38
124683, Г. МОСКВА, ЗЕЛЕНОГРАД, КОРП. 1824
124683, Г. МОСКВА, ЗЕЛЕНОГРАД, КОРП.1824
125167, Г. МОСКВА, ПР-КТ ЛЕНИНГРАДСКИЙ, Д. 39А, ПОМ. 1
125171, Г. МОСКВА, ЛЕНИНГРАДСКОЕ ШОССЕ, Д. 15
125239, Г. МОСКВА, УЛ. КОПТЕВСКАЯ, Д. 69А, СТР. 1.
125239, Г. МОСКВА, УЛ. КОПТЕВСКАЯ, Д. 69А, СТР. 2
125239, Г. МОСКВА, УЛ. КОПТЕВСКАЯ, Д. 69А, СТР. 5
125252, Г. МОСКВА, УЛ. АВИАКОНСТРУКТОРА МИКОЯНА, Д. 12, ПОМ. 15/1
125252, Г. МОСКВА, УЛ. АВИАКОНСТРУКТОРА МИКОЯНА, Д. 12, ПОМ. 15/1, КАБ. 1
125252, ГОРОД МОСКВА, УЛ. ЗОРГЕ, Д. 17
125315, ГОРОД МОСКВА, ЛЕНИНГРАДСКИЙ ПР-КТ, Д. 78, КОРП. 1
125315, ГОРОД МОСКВА, ЛЕНИНГРАДСКИЙ ПР-КТ, Д. 78, КОРП. 1, ПОМ. 1-014
125363, Г. МОСКВА, Б-Р ЯНА РАЙНИСА, Д. 1
125363, Г. МОСКВА, Б-Р ЯНА РАЙНИСА, Д. 1, ПОМ. 1 (14.2 КВ. М)
125375, Г. МОСКВА, УЛ. ТВЕРСКАЯ, Д. 28, КОРП. 1, ПОМ. 1-015
125430, Г. МОСКВА, УЛ. МИТИНСКАЯ, Д. 36, КОРП. 1, ПОМ. 1, КОМН. 12, 13
125430, Г. МОСКВА, УЛ. МИТИНСКАЯ, Д. 36, КОРП. 1, ПОМ. 1, КОМН. 6-11, 14-19, 22-27
125445, Г. МОСКВА, ЛЕНИНГРАДСКОЕ ШОССЕ, Д. 71А, СТР.10
125466, Г. МОСКВА, УЛ. ВОРОТЫНСКАЯ, Д. 2
125466, РФ, Г. МОСКВА, УЛ. ВОРОТЫНСКАЯ, Д. 2, ПОМ. 1 (23,56 КВ.М)
125476, Г. МОСКВА, УЛ. ВАСИЛИЯ ПЕТУШКОВА, Д. 3, КОРП. 2
125565, Г. МОСКВА, УЛ. ФЕСТИВАЛЬНАЯ, Д. 13, КОРП. 1
125565, Г. МОСКВА, УЛ. ФЕСТИВАЛЬНАЯ, ДОМ 13, КОРП. 1
127015, Г. МОСКВА, УЛ. БУТЫРСКАЯ, Д. 89
127015, Г. МОСКВА, УЛ. БУТЫРСКАЯ, Д. 89, ПОМ. 9
127030, Г. МОСКВА, УЛ. НОВОСЛОБОДСКАЯ, Д. 23
127224, Г. МОСКВА, УЛ. ОСТАШКОВСКАЯ, Д. 22
127247, Г. МОСКВА, Ш. ДМИТРОВСКОЕ, Д. 107Д
127282, Г. МОСКВА, УЛ. ГРЕКОВА, Д. 3, КОРП. 3
127287, Г. МОСКВА, УЛ. БАШИЛОВСКАЯ, Д. 2, СТР. 3
127349, Г. МОСКВА, УЛ. ЛЕСКОВА, ДОМ 3Г
127434, Г. МОСКВА, Ш. ДМИТРОВСКОЕ, Д. 15, КОРП. 1
127434, Г. МОСКВА, Ш. ДМИТРОВСКОЕ, Д. 15, КОРП. 1, ПОМ. I, КОМНАТА №14
127566, Г. МОСКВА, УЛ. ДЕКАБРИСТОВ, Д. 17
127566, Г. МОСКВА, УЛ. РИМСКОГО-КОРСАКОВА, Д. 3, СТР. 1, ПОМ. 1/1
129085, Г. МОСКВА, ПР-КТ МИРА, Д. 91, КОРП. 2, ПОМ. 1-021
129085, Г. МОСКВА, ПРОСПЕКТ МИРА, Д. 91, КОРП. 2
129085, Г. МОСКВА, ПРОСПЕКТ МИРА, Д.91, КОРП.2
129090, Г. МОСКВА, ПРОСПЕКТ МИРА, Д. 42
129090, Г. МОСКВА, ПРОСПЕКТ МИРА, Д. 42, ПОМ. 10
129327, Г. МОСКВА, УЛ. МЕНЖИНСКОГО, Д. 23, КОРП. 1
129327, Г. МОСКВА, УЛ. МЕНЖИНСКОГО, Д. 23, КОРП. 1, ПОМ. 7
129337, Г. МОСКВА, ЯРОСЛАВСКОЕ Ш., Д. 11, КОРП. 1
129337, Г. МОСКВА, ЯРОСЛАВСКОЕ Ш., Д. 36
129337, ГОРОД МОСКВА, ЯРОСЛАВСКОЕ Ш., Д. 7
140000, МОСКОВСКАЯ ОБЛАСТЬ, Г. ЛЮБЕРЦЫ, ОКТЯБРЬСКИЙ ПРОСПЕКТ, Д. 151/9, П. 2
140000, МОСКОВСКАЯ ОБЛАСТЬ, Г. ЛЮБЕРЦЫ, ОКТЯБРЬСКИЙ ПРОСПЕКТ, Д. 151/9, ПОМ. 1
140053, МОСКОВСКАЯ ОБЛ., Г. КОТЕЛЬНИКИ, КОММЕРЧЕСКИЙ ПРОЕЗД, Д.10
140056, МОСКОВСКАЯ ОБЛАСТЬ, Г. КОТЕЛЬНИКИ, УЛ. СОСНОВАЯ, Д. 2, КОРП. 4
140080, МОСКОВСКАЯ ОБЛАСТЬ, Г. ЛЫТКАРИНО, УЛ. ПАРКОВАЯ, СТР. 2
140090, МОСКОВСКАЯ ОБЛАСТЬ, Г. ДЗЕРЖИНСКИЙ, УЛ. ЛЕСНАЯ, Д. 4
140108, МОСКОВСКАЯ ОБЛАСТЬ, ГОРОДСКОЙ ОКРУГ РАМЕНСКИЙ, ГОРОД РАМЕНСКОЕ, УЛИЦА МИХАЛЕВИЧА, ДОМ 1А
140184, МОСКОВСКАЯ ОБЛАСТЬ, Г. ЖУКОВСКИЙ, УЛ. ГАГАРИНА, Д. 24
140200, МОСКОВСКАЯ ОБЛАСТЬ, Г. ВОСКРЕСЕНСК, УЛИЦА ОКТЯБРЬСКАЯ, ДОМ 2, ПОМ. 31
140301, МОСКОВСКАЯ ОБЛАСТЬ, Г. ЕГОРЬЕВСК, УЛ. СОВЕТСКАЯ, Д. 82 «А»
140408, МОСКОВСКАЯ ОБЛАСТЬ, Г. КОЛОМНА, ПЛОЩАДЬ ВОССТАНИЯ, Д. 7, ПОМ. 131-132, ЧАСТЬ ПОМ. 134
141006, МОСКОВСКАЯ ОБЛАСТЬ, Г. МЫТИЩИ, ОЛИМПИЙСКИЙ ПРОСПЕКТ, ДОМ 19, КОРП. 1
141008, МОСКОВСКАЯ ОБЛАСТЬ, Г. МЫТИЩИ, УЛ. МИРА, СТРОЕНИЕ 26А
141011, МОСКОВСКАЯ ОБЛ., Г. МЫТИЩИ, УЛ. 3-Я КОЛХОЗНАЯ, Д. 9
141011, МОСКОВСКАЯ ОБЛАСТЬ, Г. МЫТИЩИ, УЛ. 3-Я КОЛХОЗНАЯ, Д.9 "А"
141031, МОСКОВСКАЯ ОБЛАСТЬ, Г. МЫТИЩИ, П. ВЕШКИ, ТЕР. ТПЗ АЛТУФЬЕВО, КИЛОМЕТР МКАД 85, СТР. 3А
141031, МОСКОВСКАЯ ОБЛАСТЬ, Г.О. МЫТИЩИ, Г. МЫТИЩИ, ТЕРРИТОРИЯ ТПЗ АЛТУФЬЕВО, ПРОЕЗД АВТОМОБИЛЬНЫЙ, ВЛАД. 5 СТР. 9
141080, МОСКОВСКАЯ ОБЛ., РОССИЯ, Г. КОРОЛЕВ, ПР-Т КОСМОНАВТОВ, Д. 27 Б
141151, МОСКОВСКАЯ ОБЛАСТЬ, Г. ЛОСИНО-ПЕТРОВСКИЙ, УЛ. КИРОВА, Д. 6А, ПОМ. 1-003
141195, МОСКОВСКАЯ ОБЛАСТЬ, Г. ФРЯЗИНО, ПР-КТ МИРА, Д. 8, ПОМ. 2003/2
141207, МОСКОВСКАЯ ОБЛАСТЬ, Г. ПУШКИНО, МОСКОВСКИЙ ПРОСПЕКТ, Д. 9/2
141281, МОСКОВСКАЯ ОБЛАСТЬ, Г. ИВАНТЕЕВКА, УЛ. ХЛЕБОЗАВОДСКАЯ, Д. 30, ПОМ. 1-002
141310, МОСКОВСКАЯ ОБЛАСТЬ, Г. СЕРГИЕВ ПОСАД, ПРОСП. КРАСНОЙ АРМИИ, Д. 180, ПОМ. 128
141400, РФ, МОСКОВСКАЯ ОБЛ., Г. ХИМКИ, ПР-Т МЕЛЬНИКОВА, Д. 2Б, ПОМ. 1 (14,35 КВ.М.)
141407, МОСКОВСКАЯ ОБЛАСТЬ, Г. ХИМКИ, 78 КМ. МКАД, ВЛ.2
141407, МОСКОВСКАЯ ОБЛАСТЬ, Г. ХИМКИ, КМ. МКАД 74-Й, ВЛД. 5
141408, МОСКОВСКАЯ ОБЛ., Г. ХИМКИ, ЛЕНИНГРАДСКОЕ Ш., ВЛ. 21
141408, МОСКОВСКАЯ ОБЛАСТЬ, Г. ХИМКИ, ЛЕНИНГРАДСКОЕ Ш., ВЛ. 21, ПОМ. 1
141410, МОСКОВСКАЯ ОБЛАСТЬ, Г. ХИМКИ, ЛЕНИНГРАДСКОЕ Ш., ВЛД. 14 СТР. 1
141506, МОСКОВСКАЯ ОБЛАСТЬ, Г. СОЛНЕЧНОГОРСК, УЛ. ИМЕНИ БАРАНОВА, Д. 12
141607, МОСКОВСКАЯ ОБЛАСТЬ, Г. КЛИН, УЛ. КАРЛА МАРКСА, Д. 4
141700, МОСКОВСКАЯ ОБЛАСТЬ, Г. ДОЛГОПРУДНЫЙ, НОВЫЙ БУЛЬВАР, Д. 2
141730, МОСКОВСКАЯ ОБЛАСТЬ, Г. ЛОБНЯ, УЛ. ЛЕНИНА, Д. 16
141800, МОСКОВСКАЯ ОБЛАСТЬ, Г. ДМИТРОВ, УЛ. ЗАГОРСКАЯ, Д. 24, ПОМ. ЛИТБ58
141986, МОСКОВСКАЯ ОБЛАСТЬ, Г. ДУБНА, ПРОСПЕКТ БОГОЛЮБОВА, Д. 14А
142000, МОСКОВСКАЯ ОБЛАСТЬ, Г. ДОМОДЕДОВО, МИКРОРАЙОН ЦЕНТРАЛЬНЫЙ, КАШИРСКОЕ ШОССЕ, Д. 51
142034, МОСКОВСКАЯ ОБЛАСТЬ, Г. ВИДНОЕ, ПОС. МЕЩЕРИНО, ЗОНА ПРИДОРОЖНОГО СЕРВИСА АВТОДОРОГИ МКАД-АЭРОПОРТ ДОМОДЕДОВО, ВЛД. 5
142100, МОСКОВСКАЯ ОБЛАСТЬ, Г. ПОДОЛЬСК, УЛ. РАБОЧАЯ, Д. 4
142100, МОСКОВСКАЯ ОБЛАСТЬ, Г. ПОДОЛЬСК, УЛ. РАБОЧАЯ, Д. 4, ЧАСТЬ ПОМ. 1
142103, МОСКОВСКАЯ ОБЛАСТЬ, ГОРОДСКОЙ ОКРУГ ПОДОЛЬСК, ТЕРРИТОРИЯ АВТОДОРОГА М-2 КРЫМ, КИЛОМЕТР 30-Й, Д. 1, СТР. 1
142118, МОСКОВСКАЯ ОБЛАСТЬ, Г. ПОДОЛЬСК, МКР. КУЗНЕЧИКИ, УЛ. АКАДЕМИКА ДОЛЛЕЖАЛЯ, Д. 3, СТР. 2
142203, МОСКОВСКАЯ ОБЛАСТЬ, Г. СЕРПУХОВ, УЛ. ВОРОШИЛОВА, Д. 127
142281, МОСКОВСКАЯ ОБЛАСТЬ, Г. ПРОТВИНО, УЛ. ЛЕНИНА, Д. 22
142400, МОСКОВСКАЯ ОБЛАСТЬ, Г. НОГИНСК, УЛ. 3-ГО ИНТЕРНАЦИОНАЛА, Д. 62
142500, МОСКОВСКАЯ ОБЛАСТЬ, Г. ПАВЛОВСКИЙ ПОСАД, УЛ. БОЛЬШАЯ ПОКРОВСКАЯ, Д. 10
142703, МОСКОВСКАЯ ОБЛАСТЬ, Г. ВИДНОЕ, УЛ. ШКОЛЬНАЯ, 23
142715, МОСКОВСКАЯ ОБЛАСТЬ, ЛЕНИНСКИЙ Г.О., Д. БЛИЖНИЕ ПРУДИЩИ, 27 КМ МКАД, ВЛ. 9
142718, МОСКОВСКАЯ ОБЛ., ЛЕНИНСКИЙ Р-Н, СЕЛЬСКОЕ ПОСЕЛЕНИЕ БУЛАТНИКОВСКОЕ, РАЙОН 29 КМ МКАД, УЧ. 1 (ЧАСТЬ ПОМ. 104)
142718, МОСКОВСКАЯ ОБЛАСТЬ, ЛЕНИНСКИЙ ГОРОДСКОЙ ОКРУГ, ПОС. БИТЦА ТЕР. МКАД, УЛ. 32-Й КМ., СТР. 3
142784, Г. МОСКВА, П. МОСКОВСКИЙ, КИЕВСКОЕ ШОССЕ, 22-Й КМ., ДОМОВЛ. 4, СТР. 1
142784, Г.МОСКВА, П. МОСКОВСКИЙ, КИЕВСКОЕ ШОССЕ, 22-Й КМ., ДОМОВЛ. 4, СТР. 1
142802, МОСКОВСКАЯ ОБЛАСТЬ, Г. СТУПИНО, УЛ. АНДРОПОВА, Д. 63
142901, МОСКОВСКАЯ ОБЛАСТЬ, Г. КАШИРА, УЛ. ИЛЬИЧА, Д. 61
143005, МОСКОВСКАЯ ОБЛ., Г. ОДИНЦОВО, МОЖАЙСКОЕ Ш., Д. 76 А, 2 ЭТАЖ, ПОМ. 1 (23,9 КВ.М.)
143005, МОСКОВСКАЯ ОБЛАСТЬ, Г. ОДИНЦОВО, МОЖАЙСКОЕ ШОССЕ, ДОМ 76А
143025, МОСКОВСКАЯ ОБЛАСТЬ, Г.О. ОДИНЦОВСКИЙ, С. НЕМЧИНОВКА, УЛ. МОСКОВСКАЯ, Д. 61
143082, МОСКОВСКАЯ ОБЛАСТЬ, ГОРОДСКОЙ ОКРУГ ОДИНЦОВСКИЙ, Д. РАЗДОРЫ, ТЕР. МКАД КМ. 61, СТР. 1
143085, МОСКОВСКАЯ ОБЛАСТЬ, ОДИНЦОВСКИЙ РАЙОН, РАБОЧИЙ ПОСЕЛОК ЗАРЕЧЬЕ, УЛ. ТОРГОВАЯ, Д.4, СТР. 1
143090, МОСКОВСКАЯ ОБЛАСТЬ, Г. КРАСНОЗНАМЕНСК, УЛ. МОЛОДЕЖНАЯ, ЗД. 2/1
143132, МОСКОВСКАЯ ОБЛАСТЬ, Г. РУЗА, П.Г.Т. ТУЧКОВО, УЛ. ПРИВОКЗАЛЬНАЯ ПЛОЩАДЬ, Д. 9, ПОМ. 22, 25, 26
143200, МОСКОВСКАЯ ОБЛАСТЬ, Г.О. МОЖАЙСКИЙ, Г. МОЖАЙСК, УЛ. МИРА, Д. 11/1
143300, МОСКОВСКАЯ ОБЛАСТЬ, Г. НАРО-ФОМИНСК, ПЛОЩАДЬ СВОБОДЫ, Д. 2
143360, МОСКОВСКАЯ ОБЛАСТЬ, Г.О. НАРО-ФОМИНСКИЙ, Г. АПРЕЛЕВКА, УЛ. АВГУСТОВСКАЯ, Д. 14
143404, МОСКОВСКАЯ ОБЛАСТЬ, Г. КРАСНОГОРСК, УЛ. ЛЕНИНА, ДОМ 38Б
143432, МОСКОВСКАЯ ОБЛАСТЬ, Г. КРАСНОГОРСК, Р.П. НАХАБИНО, УЛ. ИНСТИТУТСКАЯ, Д. 17
143500, МОСКОВСКАЯ ОБЛАСТЬ, Г. ИСТРА, УЛ. ЛЕНИНА, Д. 75, КОМ. № 105, 106
143600, МОСКОВСКАЯ ОБЛАСТЬ, Г. ВОЛОКОЛАМСК, УЛ. СОВЕТСКАЯ, Д. 1/1
143912, МОСКОВСКАЯ ОБЛАСТЬ, Г. БАЛАШИХА, Ш. ЭНТУЗИАСТОВ, Д. 12Б
143912, МОСКОВСКАЯ ОБЛАСТЬ, Г. БАЛАШИХА, ШОССЕ ЭНТУЗИАСТОВ, ДОМ 7/1, ПОМ. 1
143914, МОСКОВСКАЯ ОБЛАСТЬ, Г. БАЛАШИХА, МКРН. ЦОВБ, Д. 21, ПОМ. 1
143960, МОСКОВСКАЯ ОБЛАСТЬ, Г. РЕУТОВ, 2-3 КМ МКАД, Д. 7
143960, МОСКОВСКАЯ ОБЛАСТЬ, ГОРОДСКОЙ ОКРУГ РЕУТОВ, Г. РЕУТОВ, 2-3 КМ. МКАД, ДОМ 7
143964, МОСКОВСКАЯ ОБЛАСТЬ, Г. РЕУТОВ, УЛ. МКАД 3 КМ, Д.11
143965, МОСКОВСКАЯ ОБЛАСТЬ, Г. РЕУТОВ, НОСОВИХИНСКОЕ ШОССЕ, ДОМ 7, ПОМЕЩЕНИЕ V
143980, МОСКОВСКАЯ ОБЛАСТЬ, ГОРОДСКОЙ ОКРУГ БАЛАШИХА, Г. БАЛАШИХА, МИКРОРАЙОН ЖЕЛЕЗНОДОРОЖНЫЙ, УЛ. ПРОЛЕТАРСКАЯ, Д. 2А, ПОМЕЩЕНИЕ 1
152020, ЯРОСЛАВСКАЯ ОБЛАСТЬ, Г. ПЕРЕСЛАВЛЬ-ЗАЛЕССКИЙ, УЛ. РОСТОВСКАЯ, Д. 4
152155, ЯРОСЛАВСКАЯ ОБЛАСТЬ, РОСТОВСКИЙ Р-Н, Г. РОСТОВ ВЕЛИКИЙ, УЛ. КАРЛА МАРКСА, Д. 10
152300, ЯРОСЛАВСКАЯ ОБЛАСТЬ, Г. ТУТАЕВ, ПР-КТ 50-ЛЕТИЯ ПОБЕДЫ, ЗД. 9А
152615, ЯРОСЛАВСКАЯ ОБЛАСТЬ, УГЛИЧСКИЙ РАЙОН, Г. УГЛИЧ, УЛ. ЯРОСЛАВСКАЯ, Д. 8
152903, ЯРОСЛАВСКАЯ ОБЛАСТЬ, РЫБИНСКИЙ РАЙОН, Г. РЫБИНСК, УЛ. КИРОВА, Д. 18
153000, Г. ИВАНОВО, ПРОСПЕКТ ЛЕНИНА, 11
153000, Г. ИВАНОВО, ПРОСПЕКТ ЛЕНИНА, 11, П. 1
153038, ИВАНОВСКАЯ ОБЛАСТЬ, Г. ИВАНОВО, ПР-КТ СТРОИТЕЛЕЙ, Д. 25
153511, ИВАНОВСКАЯ ОБЛАСТЬ, Г. КОХМА, УЛ. ИВАНОВСКАЯ, Д. 29, ПОМ. 2, 3
155040, ИВАНОВСКАЯ ОБЛАСТЬ, ТЕЙКОВСКИЙ РАЙОН, ГОРОД ТЕЙКОВО, УЛИЦА ОКТЯБРЬСКАЯ, Д. 12, ПОМ. 1005
155800, ИВАНОВСКАЯ ОБЛАСТЬ, Г. КИНЕШМА, УЛ. ИМ ЛЕНИНА, Д. 6, КВ. 1
156000, КОСТРОМСКАЯ ОБЛАСТЬ, Г. КОСТРОМА, УЛ. СОВЕТСКАЯ, Д. 30
156000, КОСТРОМСКАЯ ОБЛАСТЬ, Г. КОСТРОМА, УЛ. СОВЕТСКАЯ, Д. 30, ПОМ. 1-004
156013, КОСТРОМСКАЯ ОБЛАСТЬ, Г. КОСТРОМА, ПР-Т МИРА, Д. 65/51, ПОМ. 2
156901, КОСТРОМСКАЯ ОБЛАСТЬ, Г. ВОЛГОРЕЧЕНСК, УЛ. ИМЕНИ 50-ЛЕТИЯ ЛЕНИНСКОГО КОМСОМОЛА, Д. 35
157006, КОСТРОМСКАЯ ОБЛАСТЬ, Г. БУЙ, УЛ. ОКТЯБРЬСКОЙ РЕВОЛЮЦИИ, ЗД. 49А
157800, КОСТРОМСКАЯ ОБЛАСТЬ, НЕРЕХТСКИЙ Р-Н, Г. НЕРЕХТА, УЛ. МЕТАЛЛИСТОВ, Д. 4
160000, ВОЛОГОДСКАЯ ОБЛАСТЬ, Г. ВОЛОГДА, УЛ. ЛЕНИНА, Д. 5
162130, ВОЛОГОДСКАЯ ОБЛАСТЬ, Г. СОКОЛ, УЛ. СОВЕТСКАЯ, Д. 87
162390, ВОЛОГОДСКАЯ ОБЛАСТЬ, ВЕЛИКОУСТЮГСКИЙ Р-Н, Г. ВЕЛИКИЙ УСТЮГ, ПР-КТ СОВЕТСКИЙ, Д. 99
163000, Г. АРХАНГЕЛЬСК, УЛ. ВОСКРЕСЕНСКАЯ, Д. 6
163060, Г. АРХАНГЕЛЬСК, УЛ. ВОСКРЕСЕНСКАЯ, Д.108
164171, АРХАНГЕЛЬСКАЯ ОБЛАСТЬ, Г. МИРНЫЙ, УЛ. ЛЕНИНА, Д. 26, ПОМ. 35
164504, АРХАНГЕЛЬСКАЯ ОБЛАСТЬ, Г. СЕВЕРОДВИНСК, УЛ. ЛОМОНОСОВА, Д. 64, ПОМ. 20020, КОМН. №17; №18; №20; №21; №23, ЧАСТЬ КОМНАТЫ №25; №26
165150, АРХАНГЕЛЬСКАЯ ОБЛАСТЬ, Г. ВЕЛЬСК, УЛ. ДЗЕРЖИНСКОГО, Д. 86, СТР. 3
165313, АРХАНГЕЛЬСКАЯ ОБЛАСТЬ, Г. КОТЛАС, УЛ. НЕВСКОГО, Д. 20
165653, АРХАНГЕЛЬСКАЯ ОБЛАСТЬ, Г. КОРЯЖМА, ПРОСПЕКТ ЛЕНИНА, Д. 11, ПОМ. 4
166000, НЕНЕЦКИЙ АВТОНОМНЫЙ ОКРУГ, Г. НАРЬЯН-МАР, УЛИЦА ИМ. В.И. ЛЕНИНА, Д. 19, ПОМ. 1-Н
167001, РЕСПУБЛИКА КОМИ, Г. СЫКТЫВКАР, УЛ. КОММУНИСТИЧЕСКАЯ, Д. 46
169313, РЕСПУБЛИКА КОМИ, Г. УХТА, ПРОСПЕКТ ЛЕНИНА, Д. 27
169600, РЕСПУБЛИКА КОМИ, Г. ПЕЧОРА, УЛ. ГАГАРИНА, Д. 41
169710, РЕСПУБЛИКА КОМИ, Г. УСИНСК, УЛ. НЕФТЯНИКОВ, Д. 50
170100, Г.ТВЕРЬ, УЛ. ЖЕЛЯБОВА, Д. 3
170100, РФ, ТВЕРСКАЯ ОБЛ., Г. ТВЕРЬ, УЛ. ЖЕЛЯБОВА, Д. 3, ПОМ. 1-11 (132,2 КВ.М.)
170100, ТВЕРСКАЯ ОБЛАСТЬ, Г. ТВЕРЬ, УЛ. ЖЕЛЯБОВА, Д. 3, ПОМ.12
170100, ТВЕРСКАЯ ОБЛАСТЬ, Г. ТВЕРЬ, УЛ. ТРЕХСВЯТСКАЯ, Д. 28
170100, ТВЕРСКАЯ ОБЛАСТЬ, Г. ТВЕРЬ, УЛ. ТРЕХСВЯТСКАЯ, Д. 28, КАБ. 1-011
171163, ТВЕРСКАЯ ОБЛАСТЬ, Г. ВЫШНИЙ ВОЛОЧЕК, ПРОСПЕКТ КАЗАНСКИЙ, Д. 44, КВ. 1005
171252, ТВЕРСКАЯ ОБЛ., Г. КОНАКОВО, ПР-Т ЛЕНИНА, Д. 12
171506, ТВЕРСКАЯ ОБЛАСТЬ, Г. КИМРЫ, УЛ. УРИЦКОГО, Д. 4/6
171841, ТВЕРСКАЯ ОБЛАСТЬ, Г. УДОМЛЯ, УЛ. ПОПОВА, Д. 23
172008, ТВЕРСКАЯ ОБЛАСТЬ, Г. ТОРЖОК, УЛ. СТЕПАНА РАЗИНА, Д. 2-4
172390, ТВЕРСКАЯ ОБЛАСТЬ, Г. РЖЕВ, УЛИЦА ЛЕНИНА, Д. 7, ПОМ. 1
172527, ТВЕРСКАЯ ОБЛАСТЬ, Г. НЕЛИДОВО, УЛ. МАТРОСОВА, Д. 29
173001, НОВГОРОДСКАЯ ОБЛАСТЬ, Г. ВЕЛИКИЙ НОВГОРОД, УЛ. БОЛЬШАЯ САНКТ-ПЕТЕРБУРГСКАЯ, Д. 13, ПОМ. 1-15
174411, НОВГОРОДСКАЯ ОБЛАСТЬ, БОРОВИЧСКИЙ РАЙОН, Г. БОРОВИЧИ, УЛ. ПОДБЕЛЬСКОГО, Д. 36
175204, НОВГОРОДСКАЯ ОБЛАСТЬ, Г. СТАРАЯ РУССА, УЛ. ГОСТИНОДВОРСКАЯ, Д. 4/18, ПОМЕЩ. 16
180004, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ПСКОВСКАЯ ОБЛАСТЬ, Г. ПСКОВ, ЯНА ФАБРИЦИУСА УЛ., ДОМ 27, ПОМ. 1005 (ЧАСТЬ КОМНАТЫ №15, ЧАСТЬ КОМНАТЫ №17, КОМНАТА №18, КОМНАТА №19, КОМНАТА №20, ЧАСТЬ КОМНАТЫ №21)
180007, ПСКОВСКАЯ ОБЛ., Г. ПСКОВ, ПР-Т РИЖСКИЙ, Д. 16
182113, ПСКОВСКАЯ ОБЛАСТЬ, Г. ВЕЛИКИЕ ЛУКИ, ПРОСПЕКТ ЛЕНИНА, Д. 39
183038, Г. МУРМАНСК, ПР. ЛЕНИНА, Д. 19А
183038, МУРМАНСКАЯ ОБЛАСТЬ, Г. МУРМАНСК, ПР-КТ ЛЕНИНА, Д. 82, ЛИТЕРА А4, ПОМ. X, КОМНАТА №30
183052, МУРМАНСКАЯ ОБЛАСТЬ, Г. МУРМАНСК, ПР-Т КОЛЬСКИЙ, Д. 158, II ЛИТЕР Б1, ПОМ. 35, 36, 37, 2 (ЧАСТЬ)
184042, МУРМАНСКАЯ ОБЛАСТЬ, Г. КАНДАЛАКША, УЛИЦА ПЕРВОМАЙСКАЯ, Д. 44
184209, МУРМАНСКАЯ ОБЛАСТЬ, Г. АПАТИТЫ, УЛ. ЛЕНИНА, Д. 4
184511, МУРМАНСКАЯ ОБЛАСТЬ, Г. МОНЧЕГОРСК, ПРОСПЕКТ МЕТАЛЛУРГОВ, Д. 34
184536, МУРМАНСКАЯ ОБЛАСТЬ, Г. ОЛЕНЕГОРСК, ПРОСПЕКТ ЛЕНИНГРАДСКИЙ, Д. 5
184604, МУРМАНСКАЯ ОБЛАСТЬ, Г. СЕВЕРОМОРСК, УЛ. САФОНОВА, Д. 13, ПОМ. 1-5
185014, РЕСПУБЛИКА КАРЕЛИЯ, Г. ПЕТРОЗАВОДСК, Ш. ЛОСОСИНСКОЕ, Д. 26
185035, РФ, РЕСПУБЛИКА КАРЕЛИЯ, Г. ПЕТРОЗАВОДСК, ПР-Т ЛЕНИНА, Д. 26
186225, РЕСПУБЛИКА КАРЕЛИЯ, Г. КОНДОПОГА, УЛ. ПРОЛЕТАРСКАЯ, Д. 6, ПОМ. 1
186424, РЕСПУБЛИКА КАРЕЛИЯ, Г. СЕГЕЖА, БУЛЬВАР СОВЕТОВ, Д. 3
186930, РЕСПУБЛИКА КАРЕЛИЯ, Г. КОСТОМУКША, БУЛЬВАР ЛАЗАРЕВА, Д. 1
187000, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. ТОСНО, УЛ. ВОКЗАЛЬНАЯ, Д. 16
187112, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. КИРИШИ, ПР-КТ ГЕРОЕВ, Д. 2, КВ. 164
187342, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. КИРОВСК, УЛ. НАБЕРЕЖНАЯ, Д. 6
187406, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. ВОЛХОВ, УЛ. МОЛОДЕЖНАЯ, Д. 18
187553, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. ТИХВИН, УЛ. КАРЛА МАРКСА, Д. 50
188230, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. ЛУГА, ПРОСПЕКТ УРИЦКОГО, Д. 77, КОРП. 3
188300, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. ГАТЧИНА, ПРОСПЕКТ 25 ОКТЯБРЯ, Д. 11/13
188480, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. КИНГИСЕПП, УЛ. БОЛЬШАЯ СОВЕТСКАЯ, Д. 39Б
188541, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. СОСНОВЫЙ БОР, УЛ. СОЛНЕЧНАЯ, Д.12
188560, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. СЛАНЦЫ, УЛ. ЛЕНИНА, Д. 10, ПОМ. № 1
188640, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. ВСЕВОЛОЖСК, ВСЕВОЛОЖСКИЙ ПР-КТ, Д. 29
188650, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. СЕРТОЛОВО, МИКРОРАЙОН СЕРТОЛОВО-1, УЛ. МОЛОДЦОВА, Д. 1, КОРП. 3
188677, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. МУРИНО, БУЛЬВАР МЕНДЕЛЕЕВА, Д. 11, КОРП. 1
188693, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. КУДРОВО, ЕВРОПЕЙСКИЙ ПРОСПЕКТ, Д. 8, ПОМ. 44-Н
188800, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. ВЫБОРГ, УЛ. ВОКЗАЛЬНАЯ, Д. 7
191014, Г. САНКТ-ПЕТЕРБУРГ, БАСКОВ ПЕРЕУЛОК, Д. 2, СТР. 1, ПОМЕЩЕНИЕ 67-Н
191040 Г. САНКТ-ПЕТЕРБУРГ ЛИГОВСКИЙ ПР., 73, ЛИТЕР А
191123, Г. САНКТ-ПЕТЕРБУРГ, ФУРШТАТСКАЯ УЛИЦА, Д. 40, ЛИТ. А
191123, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. САНКТ-ПЕТЕРБУРГ, ФУРШТАТСКАЯ УЛИЦА, Д.40, ЛИТ.А (КАБИНЕТЫ №№ 1-9)
191186, Г. САНКТ-ПЕТЕРБУРГ, КАЗАНСКАЯ УЛ., 2, ЛИТ. А, ПОМ. 3Н
191186, Г. САНКТ-ПЕТЕРБУРГ, УЛ. БОЛЬШАЯ КОНЮШЕННАЯ, 19/8, ЛИТ. А, ПОМ. 24 Н-25 Н
192131, Г. САНКТ-ПЕТЕРБУРГ, УЛ. ИВАНОВСКАЯ, Д. 6, ЛИТЕРА А, ПОМ. 19-Н
192177, Г. САНКТ-ПЕТЕРБУРГ, УЛ. КАРАВАЕВСКАЯ, Д. 24, КОРП. 1, ЛИТ. А, ПОМ. 21-Н
192238, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. САНКТ-ПЕТЕРБУРГ, БУХАРЕСТСКАЯ УЛИЦА, Д. 74, КОР.3, ЛИТ.А
193168, Г. САНКТ-ПЕТЕРБУРГ, ПРОСПЕКТ БОЛЬШЕВИКОВ, Д. 17, ЛИТЕРА Ч, ПОМ. 47-Н
193231,РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. САНКТ-ПЕТЕРБУРГ, ПРОСПЕКТ БОЛЬШЕВИКОВ, ДОМ 2, ЛИТЕР А
194021, Г. САНКТ-ПЕТЕРБУРГ, ПР. ТОРЕЗА, Д. 9, ЛИТ. А
194356, Г. САНКТ-ПЕТЕРБУРГ, ПР. ЭНГЕЛЬСА, Д. 139/21, ЛИТ. А
195009, Г. САНКТ-ПЕТЕРБУРГ, УЛ. АКАДЕМИКА ЛЕБЕДЕВА, Д.31, КОРП.2, ЛИТ.А
195112, Г. САНКТ-ПЕТЕРБУРГ, НОВОЧЕРКАССКИЙ ПР., 43/17, ЛИТЕР А
195220, Г. САНКТ-ПЕТЕРБУРГ, ПРОСПЕКТ НАУКИ, Д. 14, КОР. 1, ЛИТ. А, ПОМ. 35Н
195267, Г. САНКТ-ПЕТЕРБУРГ, ПР.ПРОСВЕЩЕНИЯ, 84, КОРП.1, ЛИТ.А, ПОМ.50Н
196066, Г. САНКТ-ПЕТЕРБУРГ, МОСКОВСКИЙ ПР., Д.193, ПОМ.14Н, ЛИТ.А
196105, Г. САНКТ-ПЕТЕРБУРГ, МОСКОВСКИЙ ПРОСПЕКТ, Д. 172, ЛИТ. А, ПОМ. 11-Н
196233, Г. САНКТ-ПЕТЕРБУРГ, ПРОСПЕКТ ВИТЕБСКИЙ, Д. 101, КОРП. 1, СТР. 1, ПОМ. 538Н
196608, Г. САНКТ-ПЕТЕРБУРГ, Г. ПУШКИН, УЛ. ЛЕНИНГРАДСКАЯ, Д. 29, ЛИТ. А, ПОМ. 1-Н
196655, Г. САНКТ-ПЕТЕРБУРГ, Г. КОЛПИНО, УЛ. ТВЕРСКАЯ, Д. 34, ЛИТ. А
197022, Г. САНКТ-ПЕТЕРБУРГ, КАМЕННООСТРОВСКИЙ ПРОСПЕКТ, Д. 41, ЛИТЕРА А, ПОМ. 2-Н
197022, Г. САНКТ-ПЕТЕРБУРГ, МАЛЫЙ ПРОСПЕКТ П.С., Д. 87, ЛИТ. А, ЧАСТЬ ПОМ. 1-Н
197348, Г. САНКТ-ПЕТЕРБУРГ, КОЛОМЯЖСКИЙ ПРОСПЕКТ, ДОМ 15, КОРПУС 2, ЛИТЕРА А
197371, Г. САНКТ-ПЕТЕРБУРГ, КОМЕНДАНТСКИЙ ПРОСПЕКТ, Д. 13, КОРП. 1, ЛИТЕРА А, ПОМЕЩЕНИЕ 1-Н
197374, Г. САНКТ-ПЕТЕРБУРГ, УЛ. САВУШКИНА, Д. 124, КОРП. 1, ЛИТЕРА А, ПОМ. 10-Н
198097, Г. САНКТ-ПЕТЕРБУРГ, ПР.СТАЧЕК, 47, ЛИТЕР Е
198216, Г. САНКТ-ПЕТЕРБУРГ, ПР-Т ЛЕНИНСКИЙ, Д. 130/6, ЛИТ. А
198217, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. САНКТ-ПЕТЕРБУРГ, ДАЧНЫЙ ПРОСПЕКТ, Д.17, КОРП.3, ЛИТ.А, ПОМ.3Н
198510, Г. САНКТ-ПЕТЕРБУРГ, Г. ПЕТЕРГОФ, ПР-КТ САНКТ-ПЕТЕРБУРГСКИЙ, Д. 29, ЛИТЕРА А, ЧАСТЬ ПОМ. 1-Н
199004, Г. САНКТ ПЕТЕРБУРГ, ПР-КТ СРЕДНИЙ В.О., ДОМ 33, ЛИТЕР А, ПОМ. 10-Н
199004, Г. САНКТ-ПЕТЕРБУРГ, ВАСИЛЬЕВСКИЙ ОСТРОВ, 7-АЯ ЛИНИЯ, Д.54, ЛИТ.А, ПОМ.3Н
214004, СМОЛЕНСКАЯ ОБЛАСТЬ, Г. СМОЛЕНСК, УЛ. НИКОЛАЕВА, Д. 8
214004, СМОЛЕНСКАЯ ОБЛАСТЬ, Г. СМОЛЕНСК, УЛ. НИКОЛАЕВА, Д. 8, ПОМ. 3
214036, СМОЛЕНСКАЯ ОБЛАСТЬ, Г. СМОЛЕНСК, УЛ. ПЕТРА АЛЕКСЕЕВА, Д. 1А
215110, СМОЛЕНСКАЯ ОБЛАСТЬ, Г. ВЯЗЬМА, УЛ. 25 ОКТЯБРЯ, Д. 4
215500, СМОЛЕНСКАЯ ОБЛАСТЬ, Г. САФОНОВО, УЛ. СОВЕТСКАЯ, Д. 56
216400, СМОЛЕНСКАЯ ОБЛАСТЬ, Г. ДЕСНОГОРСК, МИКРОРАЙОН 4, Д. 7
216500, СМОЛЕНСКАЯ ОБЛАСТЬ, Г. РОСЛАВЛЬ, УЛ. ПРОЛЕТАРСКАЯ, Д. 46
236001, КАЛИНИНГРАДСКАЯ ОБЛАСТЬ, Г. КАЛИНИНГРАД, УЛ. ГЕНЕРАЛА ЧЕЛНОКОВА, Д. 11
236006, КАЛИНИНГРАДСКАЯ ОБЛАСТЬ, Г. КАЛИНИНГРАД, УЛ. ТЕАТРАЛЬНАЯ, Д. 35, ПОМ. 3
236006, КАЛИНИНГРАДСКАЯ ОБЛАСТЬ, Г. КАЛИНИНГРАД, УЛ. ШЕВЧЕНКО, Д. 11А
236022, РОССИЙСКАЯ ФЕДЕРАЦИЯ, КАЛИНИНГРАДСКАЯ ОБЛ., Г. КАЛИНИНГРАД, ПЛ. ПОБЕДЫ, Д. 4, ЛИТ. "V"
236022, РФ, КАЛИНИНГРАДСКАЯ ОБЛ., Г. КАЛИНИНГРАД, ПЛ. ПОБЕДЫ, Д. 4, ЛИТЕР "А"
236041, КАЛИНИНГРАДСКАЯ ОБЛАСТЬ, Г. КАЛИНИНГРАД, УЛ. КУЙБЫШЕВА, Д. 2
236044, КАЛИНИНГРАДСКАЯ ОБЛ., Г. КАЛИНИНГРАД, Б-Р Л. ШЕВЦОВОЙ, Д. 1А
238300, КАЛИНИНГРАДСКАЯ ОБЛАСТЬ, Г. ГУРЬЕВСК, БУЛЬВАР ПРАЖСКИЙ, ЗД. 2А
238326, КАЛИНИНГРАДСКАЯ ОБЛАСТЬ, Г. ЗЕЛЕНОГРАДСК, УЛ. ПОТЕМКИНА, Д. 15Б
238340, КАЛИНИНГРАДСКАЯ ОБЛАСТЬ, Г. СВЕТЛЫЙ, УЛ. СОВЕТСКАЯ, Д. 13, ПОМ. V ИЗ ЛИТ. А.
238750, КАЛИНИНГРАДСКАЯ ОБЛАСТЬ, Г. СОВЕТСК, УЛ. ПОБЕДЫ, Д. 21
241035, БРЯНСКАЯ ОБЛАСТЬ, Г. БРЯНСК, УЛ. КОМСОМОЛЬСКАЯ, Д. 12
241050, БРЯНСКАЯ ОБЛАСТЬ, Г. БРЯНСК, УЛ. КРАСНОАРМЕЙСКАЯ, Д. 41
241050, БРЯНСКАЯ ОБЛАСТЬ, Г. БРЯНСК, УЛ. КРАСНОАРМЕЙСКАЯ, Д. 41, ПОМ. IX
242500, БРЯНСКАЯ ОБЛАСТЬ, Г. КАРАЧЕВ, УЛ. СОВЕТСКАЯ, Д. 62
242600, БРЯНСКАЯ ОБЛАСТЬ, Г. ДЯТЬКОВО, УЛ. ЛЕНИНА, Д. 166
243020, БРЯНСКАЯ ОБЛАСТЬ, Г. НОВОЗЫБКОВ, УЛ. ПЕРВОМАЙСКАЯ, Д. 26
243140, БРЯНСКАЯ ОБЛАСТЬ, Г. КЛИНЦЫ, ПР-Т ЛЕНИНА, Д. 21
243300, БРЯНСКАЯ ОБЛАСТЬ, Г. УНЕЧА, УЛ. ИВАНОВА, Д. 1
243400, БРЯНСКАЯ ОБЛАСТЬ, Г. ПОЧЕП, УЛ. ЛЕНИНА, Д. 3
248001, РОССИЯ, КАЛУЖСКАЯ ОБЛАСТЬ, Г. КАЛУГА, УЛ. КИРОВА, Д. 14
248023, КАЛУЖСКАЯ ОБЛАСТЬ, Г. КАЛУГА, ПЛ. ПОБЕДЫ, Д. 15
248023, КАЛУЖСКАЯ ОБЛАСТЬ, Г. КАЛУГА, ПЛ. ПОБЕДЫ, Д. 15, ПОМ. 12
248023, КАЛУЖСКАЯ ОБЛАСТЬ, Г. КАЛУГА, ПЛ. ПОБЕДЫ, Д. 15, ПОМ. 13
249000, КАЛУЖСКАЯ ОБЛАСТЬ, Г. БАЛАБАНОВО, УЛ. БОРОВСКАЯ, Д. 1
249039, КАЛУЖСКАЯ ОБЛАСТЬ, Г. ОБНИНСК, ПР-КТ МАРКСА, Д. 50, ПОМ. 1
249096, КАЛУЖСКАЯ ОБЛАСТЬ, Г. МАЛОЯРОСЛАВЕЦ, УЛ. ЛЕНИНА, Д. 20
249100, КАЛУЖСКАЯ ОБЛАСТЬ, Г. ТАРУСА, УЛ. ОКТЯБРЬСКАЯ, Д. 4
249406, КАЛУЖСКАЯ ОБЛ., Г. ЛЮДИНОВО, УЛ. ЛЕНИНА, Д. 4
300026, ТУЛЬСКАЯ ОБЛАСТЬ, Г. ТУЛА, УЛ. СОВЕТСКАЯ, 10, ПОМЕЩЕНИЕ 38А
300041, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ТУЛЬСКАЯ ОБЛАСТЬ, Г. ТУЛА, УЛИЦА СОВЕТСКАЯ, Д. 10, ПОМ. 31
300041, ТУЛЬСКАЯ ОБЛ., Г. ТУЛА, ЦЕНТРАЛЬНЫЙ Р-Н, ПРОСП. ЛЕНИНА, Д. 54А
300041, ТУЛЬСКАЯ ОБЛАСТЬ, Г. ТУЛА, КРАСНОАРМЕЙСКИЙ ПР-КТ, Д. 27, КОМ. № 9
301260, ТУЛЬСКАЯ ОБЛАСТЬ, Г. КИРЕЕВСК, УЛ. ЛЕНИНА, Д. 16
301320, ТУЛЬСКАЯ ОБЛАСТЬ, Г. ВЕНЕВ, УЛ. БУНДУРИНА, ЗД. 10
301361, ТУЛЬСКАЯ ОБЛАСТЬ, АЛЕКСИНСКИЙ Р-ОН, Г. АЛЕКСИН, УЛ. МИРА, Д. 20
301430, ТУЛЬСКАЯ ОБЛАСТЬ, СУВОРОВСКИЙ Р-Н, Г. СУВОРОВ, ПР-КТ МИРА, Д. 21
301470, ТУЛЬСКАЯ ОБЛАСТЬ, ПЛАВСКИЙ Р-Н, Г. ПЛАВСК, УЛ. КОММУНАРОВ, Д. 45
301530, ТУЛЬСКАЯ ОБЛАСТЬ, БЕЛЕВСКИЙ Р-Н, Г. БЕЛЕВ, ПЛ. ОКТЯБРЯ, Д. 7
301600, ТУЛЬСКАЯ ОБЛАСТЬ, Г. УЗЛОВАЯ, УЛ. ТРЕГУБОВА, Д. 40
301650, ТУЛЬСКАЯ ОБЛАСТЬ, НОВОМОСКОВСКИЙ Р-Н, Г. НОВОМОСКОВСК, УЛ. КОМСОМОЛЬСКАЯ/УЛ. ТРУДОВЫЕ РЕЗЕРВЫ, Д. 21/34, ПОМ. 1-4, 6-11
301720, ТУЛЬСКАЯ ОБЛАСТЬ, Г. КИМОВСК, УЛ. КРЫЛОВА, Д. 2
301764, ТУЛЬСКАЯ ОБЛАСТЬ, Г. ДОНСКОЙ, МКР. ЦЕНТРАЛЬНЫЙ, УЛ. ОКТЯБРЬСКАЯ, Д. 59А
301835, ТУЛЬСКАЯ ОБЛАСТЬ, Г. БОГОРОДИЦК, УЛ. КОММУНАРОВ, Д. 59
301840, ТУЛЬСКАЯ ОБЛАСТЬ, Г. ЕФРЕМОВ, УЛ. СВЕРДЛОВА, Д. 55
302026, ОРЛОВСКАЯ ОБЛАСТЬ, Г. ОРЕЛ, УЛ. КОМСОМОЛЬСКАЯ, Д. 78
302026, ОРЛОВСКАЯ ОБЛАСТЬ, Г. ОРЕЛ, УЛ. КОМСОМОЛЬСКАЯ, Д. 78, ПОМ. 14
302030, ОРЛОВСКАЯ ОБЛАСТЬ, Г. ОРЕЛ, УЛ. МОСКОВСКАЯ, Д. 28, ПОМ. 87
303030, ОРЛОВСКАЯ ОБЛАСТЬ, Г. МЦЕНСК, УЛ. ЛЕНИНА, Д. 25
303850, ОРЛОВСКАЯ ОБЛАСТЬ, Г. ЛИВНЫ, УЛ. МАКСИМА ГОРЬКОГО, Д. 2
305000, КУРСКАЯ ОБЛАСТЬ, Г. КУРСК, УЛ. ЛЕНИНА, Д. 31, ПОМ. 1Б, КАБ. 17
305000, РФ, КУРСКАЯ ОБЛ., Г. КУРСК, УЛ. ЛЕНИНА, Д. 31, ПОМ. 1Б
305029, КУРСКАЯ ОБЛАСТЬ, Г. КУРСК, УЛ. КАРЛА МАРКСА, Д. 59А
305040, КУРСКАЯ ОБЛАСТЬ, Г. КУРСК, УЛ. СТУДЕНЧЕСКАЯ, Д. 3
306230, КУРСКАЯ ОБЛАСТЬ, Г. ОБОЯНЬ, УЛ. 3-ГО ИНТЕРНАЦИОНАЛА, Д. 40
307179, КУРСКАЯ ОБЛАСТЬ, Г. ЖЕЛЕЗНОГОРСК, УЛ. ВОИНОВ-ИНТЕРНАЦИОНАЛИСТОВ, Д. 6, ПОМ. 3
307250, КУРСКАЯ ОБЛАСТЬ, Г. КУРЧАТОВ, УЛ. САДОВАЯ, ВЛД. 11А
308009, БЕЛГОРОДСКАЯ ОБЛАСТЬ, Г. БЕЛГОРОД, УЛ. ПОПОВА, Д. 12
308009, БЕЛГОРОДСКАЯ ОБЛАСТЬ, Г. БЕЛГОРОД, УЛ. ПОПОВА, Д. 12, КАБ. 1-012
308010, БЕЛГОРОДСКАЯ ОБЛАСТЬ, Г. БЕЛГОРОД, ПР-КТ Б.ХМЕЛЬНИЦКОГО, Д. 164
308027, РОССИЙСКАЯ ФЕДЕРАЦИЯ, БЕЛГОРОДСКАЯ ОБЛ., Г. БЕЛГОРОД, УЛ. ЩОРСА, Д. 8
308034, Г. БЕЛГОРОД, ПР-КТ ВАТУТИНА, Д. 9Б
308034, РОССИЙСКАЯ ФЕДЕРАЦИЯ, БЕЛГОРОДСКАЯ ОБЛАСТЬ, Г.БЕЛГОРОД, ПР-КТ ВАТУТИНА, Д. 9Б
309070, БЕЛГОРОДСКАЯ ОБЛАСТЬ, Г. СТРОИТЕЛЬ, УЛ. ЖУКОВА, ЗД. 6В
309181, БЕЛГОРОДСКАЯ ОБЛАСТЬ, Г. ГУБКИН, УЛ. КИРОВА, Д. 53
309512, БЕЛГОРОДСКАЯ ОБЛАСТЬ, Г. СТАРЫЙ ОСКОЛ, МКР. ЖУКОВА, Д. 48, ПОМЕЩЕНИЕ 6
309640, БЕЛГОРОДСКАЯ ОБЛАСТЬ, Г. НОВЫЙ ОСКОЛ, ПЛ. ЦЕНТРАЛЬНАЯ, Д. 1
309857, БЕЛГОРОДСКАЯ ОБЛАСТЬ, Г. АЛЕКСЕЕВКА, УЛ. МОСТОВАЯ, ЗД. 90
309996, БЕЛГОРОДСКАЯ ОБЛАСТЬ, Г. ВАЛУЙКИ, УЛ. М. ГОРЬКОГО, Д. 2
309996, ТАМБОВСКАЯ ОБЛАСТЬ, Г. КОТОВСК, УЛ. ОКТЯБРЬСКАЯ, Д. 6, ПОМ. 1
344000, РОСТОВСКАЯ ОБЛАСТЬ, Г. РОСТОВ-НА-ДОНУ, КИРОВСКИЙ РАЙОН, УЛ. КРАСНОАРМЕЙСКАЯ, 206, 2 ЭТАЖ, ПОМ. 2-18
344000, РОСТОВСКАЯ ОБЛАСТЬ, Г. РОСТОВ-НА-ДОНУ. КИРОВСКИЙ РАЙОН, УЛ. КРАСНОАРМЕЙСКАЯ, 206
344002, РОССИЙСКАЯ ФЕДЕРАЦИЯ, РОСТОВСКАЯ ОБЛАСТЬ, Г. РОСТОВ-НА-ДОНУ, ПР. БУДЕННОВСКИЙ, Д. 42/50
344002, РОССИЯ, РОСТОВСКАЯ ОБЛАСТЬ, Г. РОСТОВ-НА-ДОНУ, УЛ. ТЕМЕРНИЦКАЯ, Д. 80/31/81
344010, Г. РОСТОВ-НА-ДОНУ, КИРОВСКИЙ РАЙОН, УЛ. КРАСНОАРМЕЙСКАЯ, Д. 170
344010, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. РОСТОВ-НА-ДОНУ, ПЛ. ГАГАРИНА, Д. 6/87
344037, РОСТОВСКАЯ ОБЛАСТЬ, Г. РОСТОВ-НА-ДОНУ, ПРОЛЕТАРСКИЙ РАЙОН, УЛ. 20-Я ЛИНИЯ, Д. 4/3/13
344091, РОССИЙСКАЯ ФЕДЕРАЦИЯ, РОСТОВСКАЯ ОБЛ., Г. РОСТОВ-НА-ДОНУ, ПР. КОММУНИСТИЧЕСКИЙ, Д. 27
344092, Г. РОСТОВ-НА-ДОНУ, ПР. КОСМОНАВТОВ, 15
344113, РОССИЙСКАЯ ФЕДЕРАЦИЯ, РОСТОВСКАЯ ОБЛ., Г. РОСТОВ-НА-ДОНУ, ПР. КОРОЛЕВА, Д. 14 А
344116, РОСТОВСКАЯ ОБЛАСТЬ, Г. РОСТОВ-НА-ДОНУ, ПР-КТ СТАЧКИ, ЗД. 25
346130, РОСТОВСКАЯ ОБЛАСТЬ, Г. МИЛЛЕРОВО, УЛ. РОССИЙСКАЯ, ЗД. 66
346330, РОСТОВСКАЯ ОБЛАСТЬ, Г. ДОНЕЦК, УЛ. МАКСИМА ГОРЬКОГО, Д. 67
346350, РОСТОВСКАЯ ОБЛАСТЬ, КРАСНОСУЛИНСКИЙ Р-Н, Г. КРАСНЫЙ СУЛИН, УЛ. ПЕРВОМАЙСКАЯ, Д. 10/12
346400, РОСТОВСКАЯ ОБЛАСТЬ, Г. НОВОЧЕРКАССК, УЛ. МОСКОВСКАЯ, Д. 1 / ПР-Т ПЛАТОВСКИЙ, Д. 90
346630, РОСТОВСКАЯ ОБЛАСТЬ, Г. СЕМИКАРАКОРСК, УЛ. ЛЕНИНА, Д. 112
346720, РОСТОВСКАЯ ОБЛАСТЬ, АКСАЙСКИЙ Р-Н, Г. АКСАЙ, ПР-КТ ЛЕНИНА, Д. 26
346787, РОСТОВСКАЯ ОБЛ., Г. АЗОВ, УЛ. МИРА, Д. 45/64
346880, РОСТОВСКАЯ ОБЛАСТЬ, Г.О. ГОРОД БАТАЙСК, Г. БАТАЙСК, УЛ. ЭНГЕЛЬСА, Д. 182
346918, РОСТОВСКАЯ ОБЛ., Г. НОВОШАХТИНСК, ПР-КТ ЛЕНИНА, Д. 5-А
347045, РОСТОВСКАЯ ОБЛАСТЬ, Г. БЕЛАЯ КАЛИТВА, УЛ. ЭНГЕЛЬСА, Д. 382
347382, РОСТОВСКАЯ ОБЛАСТЬ, Г. ВОЛГОДОНСК, ПР-КТ СТРОИТЕЛЕЙ, Д. 21, ПОМ. 2
347630, РОСТОВСКАЯ ОБЛАСТЬ, Г. САЛЬСК, УЛ. ПУШКИНА, Д. 15
347810, РОСТОВСКАЯ ОБЛАСТЬ, Г. КАМЕНСК-ШАХТИНСКИЙ, ПР-Т КАРЛА МАРКСА, Д. 73
347871, РОСТОВСКАЯ ОБЛАСТЬ, Г. ГУКОВО, УЛ. КАРЛА МАРКСА, Д. 72
347904, РОСТОВСКАЯ ОБЛАСТЬ, Г. ТАГАНРОГ, УЛ. ДЗЕРЖИНСКОГО/УЛ.ФРУНЗЕ, Д. 1/100
347922, РОСТОВСКАЯ ОБЛАСТЬ, Г. ТАГАНРОГ, ПЕР. 1-Й КРЕПОСТНОЙ, Д. 17
350000, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. КРАСНОДАР, УЛ. СЕДИНА, Д. 159
350001, Г. КРАСНОДАР, ЦЕНТРАЛЬНЫЙ ОКРУГ, УЛ. СТАВРОПОЛЬСКАЯ, ДОМ 184
350015, КРАСНОДАРСКИЙ КРАЙ, Г. КРАСНОДАР, УЛ. КРАСНАЯ, Д. 126
350015, КРАСНОДАРСКИЙ КРАЙ, Г. КРАСНОДАР, УЛ. КРАСНАЯ, Д. 176/1
350049, Г. КРАСНОДАР, ПРИКУБАНСКИЙ ОКРУГ, УЛ. ИМ. ТУРГЕНЕВА, ДОМ 107
350049, Г. КРАСНОДАР, УЛ. ТУРГЕНЕВА, ДОМ 107, ЛИТЕР А
350049, КРАСНОДАРСКИЙ КРАЙ, Г. КРАСНОДАР, УЛ. СЕВЕРНАЯ, Д. 233, ЧАСТЬ ПОМЕЩЕНИЯ №17
350063, Г. КРАСНОДАР, ЗАПАДНЫЙ ВНУТРИГОРОДСКОЙ ОКРУГ, УЛ. ИМ. ПУШКИНА, ДОМ №22
350075, КРАСНОДАРСКИЙ КРАЙ, Г. КРАСНОДАР, УЛ. ИМ. СЕЛЕЗНЕВА, Д. 126
350901, Г. КРАСНОДАР, ПРИКУБАНСКИЙ ОКРУГ, УЛ. 1 МАЯ, Д. 158.
352120, КРАСНОДАРСКИЙ КРАЙ, Г. ТИХОРЕЦК, УЛ. ЭНГЕЛЬСА, Д. 101
352192, КРАСНОДАРСКИЙ КРАЙ, Г. ГУЛЬКЕВИЧИ, УЛ. ЛЕНИНГРАДСКАЯ, Д. 3
352240, КРАСНОДАРСКИЙ КРАЙ, Г. НОВОКУБАНСК, УЛ. ПЕРВОМАЙСКАЯ, Д. 161
352330, КРАСНОДАРСКИЙ КРАЙ, Г. УСТЬ-ЛАБИНСК, УЛ. ОКТЯБРЬСКАЯ, Д. 99, ПОМ. 1-003
352380, КРАСНОДАРСКИЙ КРАЙ, Г. КРОПОТКИН, УЛ. КРАСНАЯ, Д. 96
352430, КРАСНОДАРСКИЙ КРАЙ, Г. КУРГАНИНСК, УЛ. МИЛИЦИОНЕРСКАЯ, Д. 62А
352500, КРАСНОДАРСКИЙ КРАЙ, Г. ЛАБИНСК, УЛ. ХАЛТУРИНА, Д. 18
352630, КРАСНОДАРСКИЙ КРАЙ, Г. БЕЛОРЕЧЕНСК, УЛ. ЛЕНИНА, Д. 145
352690, КРАСНОДАРСКИЙ КРАЙ, Г. АПШЕРОНСК, УЛ. ВОРОШИЛОВА, Д. 8А
352700, КРАСНОДАРСКИЙ КРАЙ, Г. ТИМАШЕВСК, УЛ. КРАСНАЯ, Д. 106
352800, КРАСНОДАРСКИЙ КРАЙ, ТУАПСИНСКИЙ РАЙОН, Г. ТУАПСЕ, УЛ. С. ПЕРОВСКОЙ, 2, ПОМ. 21, 28-30
352900, КРАСНОДАРСКИЙ КРАЙ, Г. АРМАВИР, УЛ. ЛЕНИНА, Д. 101А
353180, КРАСНОДАРСКИЙ КРАЙ, Г. КОРЕНОВСК, УЛ. КРАСНАЯ, Д. 112, ПОМ. № 1
353204, КРАСНОДАРСКИЙ КРАЙ, СТАНИЦА ДИНСКАЯ, УЛ. ЧАПАЕВА, Д. 83
353290, КРАСНОДАРСКИЙ КРАЙ, Г. ГОРЯЧИЙ КЛЮЧ, УЛ. ЛЕНИНА, Д. 192
353320, КРАСНОДАРСКИЙ КРАЙ, Г. АБИНСК, ПР-Т КОМСОМОЛЬСКИЙ, Д. 78
353380, КРАСНОДАРСКИЙ КРАЙ, Г. КРЫМСК, УЛ. СИНЕВА, Д. 9Б
353440, КРАСНОДАРСКИЙ КРАЙ, Г. АНАПА, УЛ. КРЫМСКАЯ, Д. 172
353460, КРАСНОДАРСКИЙ КРАЙ, Г. ГЕЛЕНДЖИК, УЛ. ХЕРСОНСКАЯ, Д. 23
353500, КРАСНОДАРСКИЙ КРАЙ, Г. ТЕМРЮК, УЛ. ЛЕНИНА, Д. 68
353560, КРАСНОДАРСКИЙ КРАЙ, Г. СЛАВЯНСК-НА-КУБАНИ, УЛ. КОВТЮХА, Д. 17
353680, КРАСНОДАРСКИЙ КРАЙ, Г. ЕЙСК, УЛ. ПОБЕДЫ, Д. 78
353730, КРАСНОДАРСКИЙ КРАЙ, СТ-ЦА КАНЕВСКАЯ, УЛ. ВОКЗАЛЬНАЯ, Д. 59
353905, КРАСНОДАРСКИЙ КРАЙ, Г. НОВОРОССИЙСК, УЛ. ТОЛСТОГО, Д. 2Г
353915, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. НОВОРОССИЙСК, ПРОСП. ДЗЕРЖИНСКОГО, Д. 205 А
354000, КРАСНОДАРСКИЙ КРАЙ, Г. СОЧИ, ЦЕНТРАЛЬНЫЙ Р-Н, УЛ. СЕВЕРНАЯ, Д. 6
354000, КРАСНОДАРСКИЙ КРАЙ, Г. СОЧИ, ЦЕНТРАЛЬНЫЙ РАЙОН, УЛ. КОНСТИТУЦИИ СССР, Д. 18, ПОМ. 14-24
354002, КРАСНОДАРСКИЙ КРАЙ, Г. СОЧИ, ХОСТИНСКИЙ РАЙОН, УЛ. УЧИТЕЛЬСКАЯ, ДОМ 3
354200, КРАСНОДАРСКИЙ КРАЙ, Г. СОЧИ, ЛАЗАРЕВСКИЙ Р-Н, УЛ. КАЛАРАША, Д. 111А, КОРП. 1
354340, КРАСНОДАРСКИЙ КРАЙ, Г. СОЧИ, АДЛЕРСКИЙ, РАЙОН, УЛ. МОЛОКОВА, Д. 18/78
355003, Г. СТАВРОПОЛЬ, УЛ. ЛЕНИНА, Д. 299
355003, СТАВРОПОЛЬСКИЙ КРАЙ, Г. СТАВРОПОЛЬ, УЛ. ЛЕНИНА, Д. 299, ПОМ. 8
355035, РФ, СТАВРОПОЛЬСКИЙ КРАЙ, Г. СТАВРОПОЛЬ, УЛ. ДЗЕРЖИНСКОГО, Д. 114, 1 ЭТАЖ, ЧАСТЬ ПОМ. № 145 (167,98 КВ. М.), ЧАСТЬ ПОМ. № 146 (1,43 КВ. М), ПОМ. № 147 (1,6 КВ. М), ЧАСТЬ ПОМ. № 148 (4,26 КВ. М)
355040, СТАВРОПОЛЬСКИЙ КРАЙ, Г. СТАВРОПОЛЬ, УЛ. 50-ЛЕТ ВЛКСМ, Д. 11, К. 1
356003, СТАВРОПОЛЬСКИЙ КРАЙ, Г. НОВОАЛЕКСАНДРОВСК, УЛ. ЖЕЛЕЗНОДОРОЖНАЯ, Д. 85А
356140, СТАВРОПОЛЬСКИЙ КРАЙ, Г. ИЗОБИЛЬНЫЙ, ПЕР. ЛЕНИНА, Д. 15
356240, СТАВРОПОЛЬСКИЙ КРАЙ, Г. МИХАЙЛОВСК, УЛ. ВОЙКОВА, Д. 454/1
356420, СТАВРОПОЛЬСКИЙ КРАЙ, Г. БЛАГОДАРНЫЙ, УЛ. ТОЛСТОГО, Д. 86А
356530, СТАВРОПОЛЬСКИЙ КРАЙ, Г. СВЕТЛОГРАД, УЛ. КОМСОМОЛЬСКАЯ, ЗД. 58/1
356630, СТАВРОПОЛЬСКИЙ КРАЙ, Г. ИПАТОВО, УЛ. СВЕРДЛОВА, ЗД. 18
356800, СТАВРОПОЛЬСКИЙ КРАЙ, Г. БУДЕННОВСК, УЛ. ОКТЯБРЬСКАЯ, Д. 79
357100, СТАВРОПОЛЬСКИЙ КРАЙ, Г. НЕВИННОМЫССК, УЛ. ГАГАРИНА, ДОМ 70Б
357207, СТАВРОПОЛЬСКИЙ КРАЙ, Г. МИНЕРАЛЬНЫЕ ВОДЫ, ПР-КТ 22 ПАРТСЪЕЗДА, ВЛД. 94/1
357400, СТАВРОПОЛЬСКИЙ КРАЙ, Г. ЖЕЛЕЗНОВОДСК, УЛ. ЛЕНИНА, Д. 55
357500, СТАВРОПОЛЬСКИЙ КРАЙ, Г. ПЯТИГОРСК, УЛ. ОКТЯБРЬСКАЯ, Д. 1
357600, СТАВРОПОЛЬСКИЙ КРАЙ, Г. ЕССЕНТУКИ, ПРИВОКЗАЛЬНАЯ ПЛОЩАДЬ, ПОМ. 1К
357700, СТАВРОПОЛЬСКИЙ КРАЙ, Г. КИСЛОВОДСК, УЛ. ГОРЬКОГО, 17Б
357821, СТАВРОПОЛЬСКИЙ КРАЙ, Г. ГЕОРГИЕВСК, УЛ. САЛОГУБОВА, Д. 16
357911, СТАВРОПОЛЬСКИЙ КРАЙ, Г. ЗЕЛЕНОКУМСК, УЛ. СОВЕТСКАЯ, Д. 11
360051, КАБАРДИНО-БАЛКАРСКАЯ РЕСПУБЛИКА, Г. НАЛЬЧИК, ПР-КТ ШОГЕНЦУКОВА, Д. 21
362008, РЕСПУБЛИКА СЕВЕРНАЯ ОСЕТИЯ-АЛАНИЯ, Г. ВЛАДИКАВКАЗ, УЛ. ГЕНЕРАЛА ПЛИЕВА, Д. 10
364024, ЧЕЧЕНСКАЯ РЕСПУБЛИКА, Г. ГРОЗНЫЙ, УЛ. ИМ С.Ш. ЛОРСАНОВА, Д. 14
367026, РЕСПУБЛИКА ДАГЕСТАН, Г. МАХАЧКАЛА, ПР-КТ ИМАМА ШАМИЛЯ, Д. 36К
385000, РЕСПУБЛИКА АДЫГЕЯ (АДЫГЕЯ), Г. МАЙКОП, УЛ. ЖУКОВСКОГО/СОВЕТСКАЯ, 20А/199
386102, РЕСПУБЛИКА ИНГУШЕТИЯ, Г. НАЗРАНЬ, УЛ. МОСКОВСКАЯ, Д. 28
390000, Г. РЯЗАНЬ, УЛ. ЛЕНИНА, Д.9
390000, РФ, РЯЗАНСКАЯ ОБЛ., Г. РЯЗАНЬ, УЛ. ЛЕНИНА, Д. 9, ПОМ. 1 (13,39 КВ.М.)
390013, РОССИЙСКАЯ ФЕДЕРАЦИЯ, РЯЗАНСКАЯ ОБЛ., Г. РЯЗАНЬ, ПЕРВОМАЙСКИЙ ПР-КТ, Д. 39/2, ПОМ. Н 10
390013, РОССИЙСКАЯ ФЕДЕРАЦИЯ, РЯЗАНСКАЯ ОБЛ., Г. РЯЗАНЬ, ПЕРВОМАЙСКИЙ ПРОСП., Д. 39/2, ПОМ. Н 10
391300, РЯЗАНСКАЯ ОБЛ., Г. КАСИМОВ, УЛ. СОВЕТСКАЯ, Д. 11
391434, РЯЗАНСКАЯ ОБЛАСТЬ, Г. САСОВО, МИКРОРАЙОН СЕВЕРНЫЙ, ЗДАНИЕ 12Г
391803, РЯЗАНСКАЯ ОБЛАСТЬ, Г. СКОПИН, УЛ. К.МАРКСА, Д. 178
391964, РЯЗАНСКАЯ ОБЛАСТЬ, Г. РЯЖСК, УЛ. М.ГОРЬКОГО, Д. 12
392000, ТАМБОВСКАЯ ОБЛАСТЬ, Г. ТАМБОВ, УЛ. СОВЕТСКАЯ/МАКСИМА ГОРЬКОГО, Д. 104/14
392000, ТАМБОВСКАЯ ОБЛАСТЬ, Г. ТАМБОВ, УЛ. СОВЕТСКАЯ/МАКСИМА ГОРЬКОГО, Д. 104/14, ПОМ. 1-008
393250, ТАМБОВСКАЯ ОБЛАСТЬ, Г. РАССКАЗОВО, УЛ. КУЙБЫШЕВСКИЙ ПРОЕЗД, Д. 2Г
393760, ТАМБОВСКАЯ ОБЛАСТЬ, Г. МИЧУРИНСК, УЛ. СОВЕТСКАЯ, Д. 323
393950, ТАМБОВСКАЯ ОБЛАСТЬ, ГОРОД МОРШАНСК, УЛИЦА ИНТЕРНАЦИОНАЛЬНАЯ, ДОМ 49
394007, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ВОРОНЕЖ, ЛЕНИНСКИЙ ПРОСПЕКТ, Д. 116А
394018, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ВОРОНЕЖ, УЛ. КИРОВА, Д. 11, ПОМ. 123, 386
394018, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ВОРОНЕЖ, УЛ. КИРОВА, Д. 11, ПОМ. 22-48
394018, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ВОРОНЕЖ, УЛ. КИРОВА, Д. 11, ПОМ. 23-30
394018, Г. ВОРОНЕЖ, УЛ. КИРОВА, Д. 11
394030, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ВОРОНЕЖ, УЛ. КОЛЬЦОВСКАЯ, Д. 31, ПОМ. III, КОМН. 1, 2, 4-17
394033, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ВОРОНЕЖ, УЛ. ОСТУЖЕВА, Д. 64
394053, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ВОРОНЕЖ, УЛ. ЗАГОРОВСКОГО, Д. 1
394088, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ВОРОНЕЖ, КОМИНТЕРНОВСКИЙ РАЙОН, УЛ. ГЕНЕРАЛА ЛИЗЮКОВА, Д. 50 (ЧАСТЬ ПОМЕЩЕНИЯ 2)
394088, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ВОРОНЕЖ, КОМИНТЕРНОВСКИЙ РАЙОН, УЛ. ГЕНЕРАЛА ЛИЗЮКОВА, Д.50
396005, ВОРОНЕЖСКАЯ ОБЛАСТЬ, РАМОНСКИЙ РАЙОН, ЯМЕНСКОЕ СЕЛЬСКОЕ ПОСЕЛЕНИЕ, П. СОЛНЕЧНЫЙ, УЛ. МОСКОВСКОЕ ШОССЕ, Д. 22
396072, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. НОВОВОРОНЕЖ, УЛ. КОСМОНАВТОВ, Д. 23/1
396310, ВОРОНЕЖСКАЯ ОБЛАСТЬ, С. НОВАЯ УСМАНЬ, УЛ. ЛЕНИНА, Д. 275, ПОМ. 1
396420, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ПАВЛОВСК, МИКРОРАЙОН СЕВЕРНЫЙ, ЗД. 23
396658, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. РОССОШЬ, ПРОСПЕКТ ТРУДА, 4Б
396790, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. БОГУЧАР, ПЛ МАЛАХОВСКОГО, Д 12/1
396900, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. СЕМИЛУКИ, УЛ. ДЗЕРЖИНСКОГО, ЗД. 10А
397160, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. БОРИСОГЛЕБСК, УЛ. СВОБОДЫ/УЛ. СОВЕТСКАЯ, Д. 213/8
397500, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. БУТУРЛИНОВКА, ПЛ. ВОЛИ, Д. 6
397700, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. БОБРОВ, УЛ. 22 ЯНВАРЯ, Д. 71/8
397855, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ОСТРОГОЖСК, УЛ. ЛЕНИНА, Д. 31М
397901, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ЛИСКИ, УЛ. КОММУНИСТИЧЕСКАЯ, Д. 36
398016, ЛИПЕЦКАЯ ОБЛАСТЬ, Г.ЛИПЕЦК, УЛ.КОСМОНАВТОВ, Д.16./1
398024, ЛИПЕЦКАЯ ОБЛАСТЬ, Г. ЛИПЕЦК, ПРОСПЕКТ ПОБЕДЫ, Д. 20, ПОМ. 3
398027, ЛИПЕЦКАЯ ОБЛАСТЬ, Г. ЛИПЕЦК, УЛ. ГЕРОЯ РОССИИ ЭДУАРДА БЕЛАНА, Д. 10А
398046, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ЛИПЕЦКАЯ ОБЛ., Г. ЛИПЕЦК, УЛ. ИМ. ГЕНЕРАЛА МЕРКУЛОВА, Д. 10 А, ПОМ. 1
398059, Г. ЛИПЕЦК, УЛ. БАРАШЕВА, Д. 7. ПОМЕЩЕНИЯ ВТОРОГО ЭТАЖА: № 2-010; ТРЕТЬЕГО ЭТАЖА: №№ 01-12; ЧЕТВЕРТОГО ЭТАЖА: №№ 01-12
398059, Г. ЛИПЕЦК, УЛ. БАРАШЕВА, Д. 7. ПОМЕЩЕНИЯ ПОДВАЛА: №№ 10-11; ПЕРВОГО ЭТАЖА: №№ 6-7.
398059, Г. ЛИПЕЦК, УЛ. БАРАШЕВА, Д.7
399050, ЛИПЕЦКАЯ ОБЛАСТЬ, Г. ГРЯЗИ, УЛ. РЕВОЛЮЦИИ 1905 ГОДА, Д. 12/26
399610, ЛИПЕЦКАЯ ОБЛАСТЬ, ЛЕБЕДЯНСКИЙ Р-Н, Г. ЛЕБЕДЯНЬ, УЛ. ПОЧТОВАЯ, ЗД. 31/1
399770, ЛИПЕЦКАЯ ОБЛАСТЬ, ГОРОД ЕЛЕЦ, УЛИЦА МИРА, ДОМ 133
400066, ВОЛГОГРАДСКАЯ ОБЛАСТЬ, Г. ВОЛГОГРАД, ПР. ИМЕНИ В.И. ЛЕНИНА, Д. 2
400066, Г. ВОЛГОГРАД УЛ. КОМСОМОЛЬСКАЯ, Д.10
400112, ВОЛГОГРАДСКАЯ ОБЛАСТЬ, Г. ВОЛГОГРАД, ПРОСПЕКТ ИМ. ГЕРОЕВ СТАЛИНГРАДА, Д. 49
400117, РОССИЯ, ВОЛГОГРАДСКАЯ ОБЛ., Г. ВОЛГОГРАД, УЛ. 8-Й ВОЗДУШНОЙ АРМИИ, Д. 47 А
400121, ВОЛГОГРАДСКАЯ ОБЛ., Г. ВОЛГОГРАД, УЛ. НИКОЛАЯ ОТРАДЫ, Д. 6
400123, ВОЛГОГРАДСКАЯ ОБЛАСТЬ, Г. ВОЛГОГРАД, УЛ. ИМ. МАРШАЛА ЕРЕМЕНКО, Д. 124
403003, ВОЛГОГРАДСКАЯ ОБЛАСТЬ, ГОРОДИЩЕНСКИЙ Р-Н, Р.П. ГОРОДИЩЕ, ПР-КТ ЛЕНИНА, Д. 6, ПОМ. 5
403113, ВОЛГОГРАДСКАЯ ОБЛАСТЬ, Г. УРЮПИНСК, УЛ. СОВЕТСКАЯ, Д. 2
403343, ВОЛГОГРАДСКАЯ ОБЛАСТЬ, Г. МИХАЙЛОВКА, УЛ. ОБОРОНЫ, Д. 44/53
403540, ВОЛГОГРАДСКАЯ ОБЛАСТЬ, Г. ФРОЛОВО, УЛ. ФРОЛОВСКАЯ, Д. 5
403873, ВОЛГОГРАДСКАЯ ОБЛАСТЬ, Г. КАМЫШИН, УЛ. ПРОЛЕТАРСКАЯ, 101, ПРИМЕРНО В 60 МЕТРАХ ПО НАПРАВЛЕНИЮ НА СЕВЕРО-ЗАПАД
404105, ВОЛГОГРАДСКАЯ ОБЛАСТЬ, Г. ВОЛЖСКИЙ, УЛ. МИРА, Д. №74 ПОМ. 1 ПОМЕЩЕНИЯ ПЕРВОГО ЭТАЖА:№1, ЧАСТЬ ПОМ. №2, №№3-12, ЧАСТЬ ПОМ. №13, ПОМ. №№ 15-16
404503, ВОЛГОГРАДСКАЯ ОБЛАСТЬ, Г. КАЛАЧ-НА-ДОНУ, УЛ. ОКТЯБРЬСКАЯ, Д. 260А
410012, РОССИЙСКАЯ ФЕДЕРАЦИЯ, САРАТОВСКАЯ ОБЛАСТЬ, Г. САРАТОВ, УЛ. ИМ. ВАСИЛИЯ ЛЮКШИНА, ЗДАНИЕ № 5
410012, САРАТОВСКАЯ ОБЛАСТЬ, Г. САРАТОВ, УЛ. МОСКОВСКАЯ, Д. 66, ПОМ. 1
410041, Г. САРАТОВ, ПР-Т СТРОИТЕЛЕЙ, Д. 19
412031, САРАТОВСКАЯ ОБЛАСТЬ, Г. РТИЩЕВО, УЛ. ЖЕЛЕЗНОДОРОЖНАЯ, Д. 48/1
412309, САРАТОВСКАЯ ОБЛАСТЬ, Г. БАЛАШОВ, УЛ. ЛЕНИНА, Д. 21Б, ПОМ. 4
412484, САРАТОВСКАЯ ОБЛАСТЬ, Г. КАЛИНИНСК, УЛ. СОВЕТСКАЯ, Д. 26
412906, САРАТОВСКАЯ ОБЛАСТЬ, Г. ВОЛЬСК, ПЛ. 10-ЛЕТИЯ ОКТЯБРЯ, Д. 4/1
413093, САРАТОВСКАЯ ОБЛАСТЬ, Г. МАРКС, УЛ. ОКТЯБРЬСКАЯ, Д. 53
413720, САРАТОВСКАЯ ОБЛАСТЬ, М.Р-Н ПУГАЧЕВСКИЙ, Г. ПУГАЧЕВ, УЛ. МАКСИМА ГОРЬКОГО, Д. 21/3
413860, САРАТОВСКАЯ ОБЛАСТЬ, Г. БАЛАКОВО, УЛ. ТРНАВСКАЯ, Д. 23
414000, Г. АСТРАХАНЬ, УЛ. КИРОВА, Д. 12/2
414040, АСТРАХАНСКАЯ ОБЛАСТЬ, Г. АСТРАХАНЬ, ПЛ. ВОКЗАЛЬНАЯ, СТР. 20Б
416506, АСТРАХАНСКАЯ ОБЛАСТЬ, Г. АХТУБИНСК, УЛ. НЕСТЕРОВА, Д. 8
416550, АСТРАХАНСКАЯ ОБЛАСТЬ, Г. ЗНАМЕНСК, ПР-КТ 9 МАЯ, Д. 16, СТР. 1
420061, РЕСПУБЛИКА ТАТАРСТАН, Г. КАЗАНЬ, УЛ. НИКОЛАЯ ЕРШОВА, Д.29"Б", ЭТАЖ 1, КАБИНЕТЫ С №1 ПО №15
420080, РЕСПУБЛИКА ТАТАРСТАН, Г.КАЗАНЬ, УЛ.ДЕКАБРИСТОВ, Д.129
420100, РЕСПУБЛИКА ТАТАРСТАН, Г. КАЗАНЬ, УЛ. ЗАКИЕВА, Д. 1
420107, РЕСПУБЛИКА ТАТАРСТАН, Г. КАЗАНЬ, УЛ. ОСТРОВСКОГО, Д. 103
420110, РЕСПУБЛИКА ТАТАРСТАН, Г.КАЗАНЬ, УЛ.РИХАРДА ЗОРГЕ, Д.75
420111, РФ, РЕСПУБЛИКА ТАТАРСТАН, Г. КАЗАНЬ, УЛ. МОСКОВСКАЯ, Д. 19/8
420126, РОССИЯ, РЕСПУБЛИКА ТАТАРСТАН, Г. КАЗАНЬ, ПР-Т ЯМАШЕВА, Д. 82
422000, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. АРСК, ПЛ. СОВЕТСКАЯ, Д. 23
422110, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. КУКМОР, УЛ. ЛЕНИНА, Д. 11А
422430, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. БУИНСК, УЛ. ЕФРЕМОВА, Д. 146, КВ. 4
422550, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. ЗЕЛЕНОДОЛЬСК, УЛ. ЛЕНИНА, Д. 25
423040, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. НУРЛАТ, УЛ. К.МАРКСА, Д. 27
423236, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. БУГУЛЬМА, УЛ. ГАЗИНУРА ГАФИАТУЛЛИНА, Д. 31
423250, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. ЛЕНИНОГОРСК, ПР-КТ 50 ЛЕТ ПОБЕДЫ, ЗД. 11А
423330, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. АЗНАКАЕВО, УЛ. ЛЕНИНА, Д. 3А
423452, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. АЛЬМЕТЬЕВСК, УЛ. ЛЕНИНА, 29
423520, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. ЗАИНСК, УЛ. ЧАПАЕВА, Д. 2, СТР.1.
423571, РЕСПУБЛИКА ТАТАРСТАН, НИЖНЕКАМСКИЙ Р-Н, Г. НИЖНЕКАМСК, ПР. МИРА, 61Г
423603, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. ЕЛАБУГА, ПР-КТ НЕФТЯНИКОВ, Д. 17
423803, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. НАБЕРЕЖНЫЕ ЧЕЛНЫ, НАБЕРЕЖНОЧЕЛНИНСКИЙ ПР-КТ, Д. 7
423812, РЕСПУБЛИКА ТАТАРСТАН, Г.НАБЕРЕЖНЫЕ ЧЕЛНЫ, ПРОСПЕКТ МИРА, Д24М (7/20)
423831, РЕСПУБЛИКА ТАТАРСТАН, Г. НАБЕРЕЖНЫЕ ЧЕЛНЫ, ПРОСП. ХАСАНА ТУФАНА, Д. 29Б, ПОМ. 37-41, 45
423930, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. БАВЛЫ, УЛ ПИОНЕРСКАЯ, Д. 13
424003, РФ, РЕСПУБЛИКА МАРИЙ ЭЛ, Г. ЙОШКАР-ОЛА, УЛ. ПЕРВОМАЙСКАЯ, Д. 164
424033, РЕСПУБЛИКА МАРИЙ ЭЛ, Г. ЙОШКАР-ОЛА, УЛ. ЭШКИНИНА, Д. 10Д
425000, РЕСПУБЛИКА МАРИЙ ЭЛ, Г. ВОЛЖСК, УЛ. ЛЕНИНА, Д. 56Б
425200, РЕСПУБЛИКА МАРИЙ ЭЛ, ПГТ. МЕДВЕДЕВО, УЛ. СОВЕТСКАЯ, Д. 24Б
425400, РЕСПУБЛИКА МАРИЙ ЭЛ, ПГТ. СОВЕТСКИЙ, УЛ. ПОБЕДЫ, ЗД. 22А
426004, УДМУРТСКАЯ РЕСПУБЛИКА, Г. ИЖЕВСК, УЛ. ЛЕНИНА, 21, ПОМ. 25, 26, 27 ,28, 29, 30
426008, УДМУРТСКАЯ РЕСПУБЛИКА, Г.ИЖЕВСК, УЛ. ПУШКИНСКАЯ, Д.216
426073, РОССИЙСКАЯ ФЕДЕРАЦИЯ, УДМУРТСКАЯ РЕСПУБЛИКА, Г. ИЖЕВСК, УЛ. МОЛОДЕЖНАЯ, Д. 87
426076, РФ, УДМУРТСКАЯ РЕСПУБЛИКА, Г. ИЖЕВСК, УЛ. ПУШКИНСКАЯ, Д. 173А
427433, УДМУРТСКАЯ РЕСПУБЛИКА, Г. ВОТКИНСК, УЛ. 1 МАЯ, Д. 102
427620, УДМУРТСКАЯ РЕСПУБЛИКА, Г.ГЛАЗОВ, УЛ.КИРОВА,Д.13
427790, УДМУРТСКАЯ РЕСПУБЛИКА, Г. МОЖГА, УЛ. МОЖГИНСКАЯ, Д. 51
427960, УДМУРТСКАЯ РЕСПУБЛИКА, Г. САРАПУЛ, УЛ. ГОРЬКОГО, Д. 15А, СТР. 1
429335, ЧУВАШСКАЯ РЕСПУБЛИКА-ЧУВАШИЯ, Г. КАНАШ, УЛ. ЖЕЛЕЗНОДОРОЖНАЯ, Д. 87
429820, ЧУВАШСКАЯ РЕСПУБЛИКА-ЧУВАШИЯ, Г. АЛАТЫРЬ, УЛ. КОМИССАРИАТСКАЯ, Д. 40 Б
429965, ЧУВАШСКАЯ РЕСПУБЛИКА-ЧУВАШИЯ, Г. НОВОЧЕБОКСАРСК, УЛ. СОВЕТСКАЯ, Д. 15
430005, РЕСПУБЛИКА МОРДОВИЯ, ЛЕНИНСКИЙ Р-Н, Г. САРАНСК, УЛ. КОММУНИСТИЧЕСКАЯ, Д. 52
430027, РЕСПУБЛИКА МОРДОВИЯ, Г. САРАНСК, УЛ. ГАГАРИНА, Д. 99Е
431440, РЕСПУБЛИКА МОРДОВИЯ, Г. РУЗАЕВКА, ПРИВОКЗАЛЬНАЯ ПЛОЩАДЬ, Д. 4
432017, УЛЬЯНОВСКАЯ ОБЛАСТЬ, Г. УЛЬЯНОВСК, УЛИЦА ГОНЧАРОВА, Д. 25
432045, УЛЬЯНОВСКАЯ ОБЛ., Г. УЛЬЯНОВСК, УЛ. ГЕРАСИМОВА, Д. 10, СТР. 4, ПОМ. 106
432067, РОССИЯ, УЛЬЯНОВСКАЯ ОБЛ., Г. УЛЬЯНОВСК, УЛЬЯНОВСКИЙ ПРОСП., Д. 6
433300, УЛЬЯНОВСКАЯ ОБЛАСТЬ, Г. НОВОУЛЬЯНОВСК, УЛ. КОМСОМОЛЬСКАЯ, Д. 20/13
433512, УЛЬЯНОВСКАЯ ОБЛАСТЬ, Г. ДИМИТРОВГРАД, ПРОСП. ЛЕНИНА, ЗДАНИЕ 39Д, ПОМЕЩ. 1
433750, УЛЬЯНОВСКАЯ ОБЛАСТЬ, Г. БАРЫШ, УЛ. 45 СТРЕЛКОВОЙ ДИВИЗИИ, ЗД.1Г
433870, УЛЬЯНОВСКАЯ ОБЛАСТЬ, Р.П. НОВОСПАССКОЕ, УЛ. МИРА, ЗД. 17
440000, ПЕНЗЕНСКАЯ ОБЛАСТЬ, Г. ПЕНЗА, ЛЕНИНСКИЙ РАЙОН, УЛ. МОСКОВСКАЯ, Д. 73
440008, ПЕНЗЕНСКАЯ ОБЛАСТЬ, Г. ПЕНЗА, УЛ. СУВОРОВА, Д. 139
440062, ПЕНЗЕНСКАЯ ОБЛАСТЬ, Г. ПЕНЗА, ПРОСПЕКТ СТРОИТЕЛЕЙ, Д. 40
440068, ПЕНЗЕНСКАЯ ОБЛАСТЬ, С. ЗАСЕЧНОЕ, УЛ. НОВО-ТЕРНОВСКАЯ, Д.1, ПОМ.1
442246, ПЕНЗЕНСКАЯ ОБЛАСТЬ, Г. КАМЕНКА, УЛ. ЧКАЛОВА, Д. 21
442530, ПЕНЗЕНСКАЯ ОБЛАСТЬ, Г. КУЗНЕЦК, УЛ. БЕЛИНСКОГО, Д. 127/1
442895, ПЕНЗЕНСКАЯ ОБЛАСТЬ, Г. СЕРДОБСК, УЛ. ЛЕНИНА, Д. 87
442963, ПЕНЗЕНСКАЯ ОБЛАСТЬ, Г. ЗАРЕЧНЫЙ, УЛ. ЛЕНИНА, Д. 20А
443001, РОССИЯ, САМАРСКАЯ ОБЛ., Г. САМАРА, ЛЕНИНСКИЙ Р-Н, УЛ. САДОВАЯ, Д. 256, ПОМ. Н 2
443009, САМАРСКАЯ ОБЛАСТЬ, Г. САМАРА, ПРОМЫШЛЕННЫЙ РАЙОН, УЛ. ПОБЕДЫ, Д. 101/ УЛ. ВОРОНЕЖСКАЯ, Д. 3, КАБ. 1-12.
443013, САМАРСКАЯ ОБЛАСТЬ, Г.САМАРА, ОКТЯБРЬСКИЙ РАЙОН, УЛ. МИЧУРИНА, Д.19 В, 1 ЭТАЖ: ПОМЕЩЕНИЯ №27-№39
443041, Г.САМАРА, ЛЕНИНСКИЙ РАЙОН, УЛ.АГИБАЛОВА, Д.76
443045, САМАРСКАЯ ОБЛАСТЬ, Г. САМАРА, ОКТЯБРЬСКИЙ РАЙОН, УЛ. ГАГАРИНА, Д. 64
443080, РОССИЯ, САМАРСКАЯ ОБЛАСТЬ, Г. САМАРА, ШОССЕ МОСКОВСКОЕ, Д. 47
443087, САМАРСКАЯ ОБЛАСТЬ, Г. САМАРА, ПРОСПЕКТ КИРОВА, Д. 228, УГОЛ УЛ. СТАРА-ЗАГОРА, Д. 126
443100, САМАРСКАЯ ОБЛАСТЬ, Г. САМАРА, УЛ. САМАРСКАЯ, Д. 220
445021, САМАРСКАЯ ОБЛАСТЬ, Г. ТОЛЬЯТТИ, УЛ. ЛЕНИНГРАДСКАЯ, ЗД. 68В
445026, РОССИЯ, САМАРСКАЯ ОБЛ., Г. ТОЛЬЯТТИ, АВТОЗАВОДСКИЙ Р-Н, УЛ. РЕВОЛЮЦИОННАЯ, Д. 32
445031, САМАРСКАЯ ОБЛАСТЬ, Г. ТОЛЬЯТТИ, УЛ. ТОПОЛИНАЯ, Д. 8А
445359, САМАРСКАЯ ОБЛАСТЬ, Г. ЖИГУЛЕВСК, МИКРОРАЙОН В-1, Д. 19
446001, РОССИЙСКАЯ ФЕДЕРАЦИЯ, САМАРСКАЯ ОБЛАСТЬ, Г. СЫЗРАНЬ, УЛ. СОВЕТСКАЯ, Д. 51
446031, САМАРСКАЯ ОБЛАСТЬ, Г. СЫЗРАНЬ, УЛ. 50 ЛЕТ ОКТЯБРЯ, Д. 54А
446200, САМАРСКАЯ ОБЛАСТЬ, Г. НОВОКУЙБЫШЕВСК, УЛ. МИРОНОВА, Д. 3
446300, САМАРСКАЯ ОБЛАСТЬ, Г. ОТРАДНЫЙ, УЛ. ОТРАДНАЯ, Д. 19, КВ. 21
446430, САМАРСКАЯ ОБЛАСТЬ, Г. КИНЕЛЬ, УЛ. МАЯКОВСКОГО, ЗД. 77/6
446450, САМАРСКАЯ ОБЛАСТЬ, Г. ПОХВИСТНЕВО, УЛ. РЕВОЛЮЦИОННАЯ, Д. 6
450008, РЕСПУБЛИКА БАШКОРТОСТАН, Г. УФА, УЛ. ЛЕНИНА, Д. 32/1, ПОМ. 1-5
450009, РЕСПУБЛИКА БАШКОРТОСТАН, Г.УФА, ПРОСПЕКТ ОКТЯБРЯ, Д.6
450039, РЕСПУБЛИКА БАШКОРТОСТАН, Г. УФА, УЛ. ТРАНСПОРТНАЯ, Д. 46
450044, РФ, РЕСПУБЛИКА БАШКОРТОСТАН, Г. УФА, УЛ. ПЕРВОМАЙСКАЯ, Д. 24
450075, РЕСПУБЛИКА БАШКОРТОСТАН, Г. УФА, ПР-КТ. ОКТЯБРЯ, Д. 107А
450099, РЕСПУБЛИКА БАШКОРТОСТАН, Г.УФА, УЛ. МАРШАЛА ЖУКОВА, Д.29.
450106, РОССИЙСКАЯ ФЕДЕРАЦИЯ, РЕСПУБЛИКА БАШКОРТОСТАН, Г. УФА, УЛ. МЕНДЕЛЕЕВА, Д. 130
452005, РЕСПУБЛИКА БАШКОРТОСТАН, Г. БЕЛЕБЕЙ, УЛ. КРАСНАЯ, Д. 134
452320, РЕСПУБЛИКА БАШКОРТОСТАН, Г. ДЮРТЮЛИ, УЛ. ЛЕНИНА, Д. 36
452410, РЕСПУБЛИКА БАШКОРТОСТАН, С. ИГЛИНО, УЛ. ГОРЬКОГО, Д. 57
452450, РЕСПУБЛИКА БАШКОРТОСТАН, Г. БИРСК, УЛ. ИНТЕРНАЦИОНАЛЬНАЯ, Д. 23
452614, РЕСПУБЛИКА БАШКОРТОСТАН, Г. ОКТЯБРЬСКИЙ, ПРОСПЕКТ ЛЕНИНА, Д. 5
452680, РЕСПУБЛИКА БАШКОРТОСТАН, Г. НЕФТЕКАМСК, УЛИЦА ЛЕНИНА, Д. 19Е
452752, РЕСПУБЛИКА БАШКОРТОСТАН, Г. ТУЙМАЗЫ, ПР-КТ ЛЕНИНА, Д. 10
453120, РЕСПУБЛИКА БАШКОРТОСТАН, Г. СТЕРЛИТАМАК, УЛ. КОММУНИСТИЧЕСКАЯ, Д. 96, ПОМ. 19
453260, РЕСПУБЛИКА БАШКОРТОСТАН, Г. САЛАВАТ, Б-Р КОСМОНАВТОВ, Д. 13
453300, РЕСПУБЛИКА БАШКОРТОСТАН, Г. КУМЕРТАУ, УЛ. КАРЛА МАРКСА, Д. 11, ПОМ. 10
453431, РЕСПУБЛИКА БАШКОРТОСТАН, Г. БЛАГОВЕЩЕНСК, УЛ. СЕДОВА, Д. 111, КВ. 22
453509, РЕСПУБЛИКА БАШКОРТОСТАН, Г. БЕЛОРЕЦК, УЛ. КАРЛА МАРКСА, Д. 75
453700, РЕСПУБЛИКА БАШКОРТОСТАН, Г. УЧАЛЫ, УЛ. ЛЕНИНА, Д. 19
453833, РЕСПУБЛИКА БАШКОРТОСТАН, Г. СИБАЙ, ПР-КТ ГОРНЯКОВ, Д. 22
453856, РЕСПУБЛИКА БАШКОРТОСТАН, Г. МЕЛЕУЗ, УЛ. ЛЕНИНА, Д. 133, ПОМ. 1
454091, Г. ЧЕЛЯБИНСК, УЛ. ЦВИЛЛИНГА, Д. 35
455000, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, ГОРОД МАГНИТОГОРСК, ПРОСПЕКТ ЛЕНИНА, ДОМ 51, ПОМ. 12
455045, ЧЕЛЯБИНСКАЯ ОБЛ., Г. МАГНИТОГОРСК, ПР. КАРЛА МАРКСА, Д. 179А
455045, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, Г. МАГНИТОГОРСК, ПР. ЛЕНИНА, Д. 90
456014, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, АШИНСКИЙ РАЙОН, Г. АША, УЛ. ЛЕНИНА, Д. 19
456080, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, Г. ТРЕХГОРНЫЙ, УЛ. МИРА, Д. 12, ПОМ. 55/2
456228, ЧЕЛЯБИНСКАЯ ОБЛ., Г. ЗЛАТОУСТ, ПР. ИМ. Ю.А. ГАГАРИНА, 3 М/Р-Н, Д. 14-В
456300, ЧЕЛЯБИНСКАЯ ОБЛ., Г. МИАСС, ПРОСП. АВТОЗАВОДЦЕВ, Д. 61 Б
456550, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, Г. КОРКИНО, УЛ. ЦВИЛЛИНГА, Д. 8
456584, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, Г. ЕМАНЖЕЛИНСК, УЛ. ТИТОВА, Д. 9
456618, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, Г. КОПЕЙСК, ПРОСПЕКТ КОММУНИСТИЧЕСКИЙ, Д. 28
456770, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, Г. СНЕЖИНСК, УЛ. СВЕРДЛОВА, Д. 36, ПОМ. 47
456800, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, Г. ВЕРХНИЙ УФАЛЕЙ, УЛ. ЛЕНИНА, Д. 190, ПОМ. 8
456870, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, Г. КЫШТЫМ, УЛ. ЛЕНИНА, Д. 6, ПОМ. 2
456880, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, АРГАЯШСКИЙ Р-Н, С. АРГАЯШ, УЛ. 8 МАРТА, Д. 36
456910, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, Р-Н САТКИНСКИЙ, Г. САТКА, УЛ. ПРОЛЕТАРСКАЯ, Д. 41, ПОМ. 12
457100, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, Г. ТРОИЦК, УЛИЦА ИМ. Ю.А. ГАГАРИНА, Д. 41
460024, ОРЕНБУРГСКАЯ ОБЛ., Г. ОРЕНБУРГ, УЛ. МАРШАЛА ЖУКОВА, Д. 26
460052, РОССИЯ, ОРЕНБУРГСКАЯ ОБЛ., Г. ОРЕНБУРГ, УЛ. САЛМЫШСКАЯ, Д. 37
461040, РФ, ОРЕНБУРГСКАЯ ОБЛ., Г. БУЗУЛУК, УЛ. КОМСОМОЛЬСКАЯ, ЗД. № 81
461505, ОРЕНБУРГСКАЯ ОБЛАСТЬ, Г. СОЛЬ-ИЛЕЦК, УЛ. СОВЕТСКАЯ, Д. 48, ЧАСТЬ ПОМ. №14
461630, ОРЕНБУРГСКАЯ ОБЛАСТЬ, Г. БУГУРУСЛАН, УЛ. РЕВОЛЮЦИОННАЯ, ЗД. 46
461900, ОРЕНБУРГСКАЯ ОБЛАСТЬ, СОРОЧИНСКИЙ РАЙОН, Г. СОРОЧИНСК, УЛ. КАРЛА МАРКСА, ЗД. 13
462274, ОРЕНБУРГСКАЯ ОБЛАСТЬ, Г. МЕДНОГОРСК, УЛ. ГЕРЦЕНА, Д. 1
462353, ОРЕНБУРГСКАЯ ОБЛАСТЬ, Г. НОВОТРОИЦК, УЛ. СОВЕТСКАЯ, Д. 52, ПОМЕЩЕНИЕ 2
462422, ОРЕНБУРГСКАЯ ОБЛАСТЬ, Г. ОРЕНБУРГ, УЛ. ЧКАЛОВА, Д. 35/1, СТРОЕН. 1
462431, ОРЕНБУРГСКАЯ ОБЛАСТЬ, Г. ОРСК, ПР-КТ ЛЕНИНА, Д. 78
600015, ВЛАДИМИРСКАЯ ОБЛ., Г. ВЛАДИМИР, ПР-Т ЛЕНИНА, Д. 26, 26А
600015, РФ, ВЛАДИМИРСКАЯ ОБЛ., Г. ВЛАДИМИР, ПР-Т ЛЕНИНА, Д. 26, 26А, ПОМ. 4
601010, ВЛАДИМИРСКАЯ ОБЛАСТЬ, КИРЖАЧСКИЙ РАЙОН, ГОРОД КИРЖАЧ, УЛИЦА ГАГАРИНА, ДОМ 29
601443, ВЛАДИМИРСКАЯ ОБЛАСТЬ, ВЯЗНИКОВСКИЙ Р-Н, Г. ВЯЗНИКИ, УЛ. ЛЕНИНА, Д. 12
601501, ВЛАДИМИРСКАЯ ОБЛАСТЬ, Г. ГУСЬ-ХРУСТАЛЬНЫЙ, УЛ. КАЛИНИНА, Д. 30, ЧАСТЬ ПОМ. № 12
601650, ВЛАДИМИРСКАЯ ОБЛАСТЬ, АЛЕКСАНДРОВСКИЙ Р-Н, Г. АЛЕКСАНДРОВ, УЛ. ЛЕНИНА, Д. 22, ПОМ. 9
601785, ВЛАДИМИРСКАЯ ОБЛАСТЬ, М.Р-Н КОЛЬЧУГИНСКИЙ, Г.П. ГОРОД КОЛЬЧУГИНО, Г. КОЛЬЧУГИНО, УЛ. 3 ИНТЕРНАЦИОНАЛА, Д. 65
601900, ВЛАДИМИРСКАЯ ОБЛАСТЬ, Г. КОВРОВ, ПРОСПЕКТ ЛЕНИНА, Д. 38
602267, ВЛАДИМИРСКАЯ ОБЛАСТЬ, Г. МУРОМ, УЛ. МОСКОВСКАЯ, Д. 47, ЧАСТЬ КОМНАТЫ №1 (48,83 КВ. М.)
603000, Г. НИЖНИЙ НОВГОРОД, НИЖЕГОРОДСКИЙ Р-Н, УЛ. НОВАЯ, Д. 8, ПОМ. П 37
603000, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. НИЖНИЙ НОВГОРОД, ВЕРХНЕ-ВОЛЖСКАЯ НАБЕРЕЖНАЯ, Д. 2А
603000, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. НИЖНИЙ НОВГОРОД, УЛ. КОСТИНА, Д. 9
603002, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. НИЖНИЙ НОВГОРОД, УЛ. ФИЛЬЧЕНКОВА, Д. 10
603003, НИЖЕГОРОДСКАЯ ОБЛ., Г. НИЖНИЙ НОВГОРОД, СОРМОВСКИЙ Р-Н, УЛ. КОМИНТЕРНА, Д. 164, ПОМ. П 5
603004, РОССИЙСКАЯ ФЕДЕРАЦИЯ, НИЖЕГОРОДСКАЯ ОБЛ., Г. НИЖНИЙ НОВГОРОД, АВТОЗАВОДСКИЙ Р-Н, ПР-Т ЛЕНИНА, Д. 100 Д, ПОМ. П 8
603024, РОССИЙСКАЯ ФЕДЕРАЦИЯ, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. НИЖНИЙ НОВГОРОД, УЛ. БЕЛИНСКОГО, Д. 61, КОРП. 2 ЦОКОЛЬНЫЙ ЭТАЖ, ПОМЕЩЕНИЯ 1-10 (182,2 КВ. М)
603035, Г. НИЖНИЙ НОВГОРОД, УЛ. ГОРЬКОГО МАКСИМА, Д. 140, ПОМ. П1
603135, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. НИЖНИЙ НОВГОРОД, ЛЕНИНСКИЙ РАЙОН, ПРОСП. ЛЕНИНА, Д. 45
606029, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. ДЗЕРЖИНСК, УЛИЦА ГАЙДАРА, Д. 61
606100, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. ПАВЛОВО, УЛ. КРАСНОАРМЕЙСКАЯ, Д. 33, СТР. 1
606408, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. БАЛАХНА, УЛ. КОММУНИСТИЧЕСКАЯ, Д. 2, ПОМ. П17
606440, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. БОР, УЛ. ЛЕНИНА, Д. 129
606502, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. ГОРОДЕЦ, УЛ. КОЛХОЗНАЯ, Д. 1
606524, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, М.О. ГОРОДЕЦКИЙ, Г. ЗАВОЛЖЬЕ, УЛ. ПОНОМАРЕВА, Д. 4
607018, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. КУЛЕБАКИ, УЛ. ВОРОВСКОГО, Д. 47
607061, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. ВЫКСА, УЛ. ОСТРОВСКОГО, ЗДАНИЕ № 33
607183, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. САРОВ, УЛ. МОСКОВСКАЯ, Д. 3, СТР. 1
607228, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. АРЗАМАС, ПРОСПЕКТ ЛЕНИНА, Д. 198, ПОМ. 1
607600, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, М.О. БОГОРОДСКИЙ, Г. БОГОРОДСК, УЛ. ЛЕНИНА, Д. 189
607651, НИЖЕГОРОДСКАЯ ОБЛ., Г. КСТОВО, УЛ. 40 ЛЕТ ОКТЯБРЯ, Д. 4, ПОМ. III (69,1 КВ.М.)
610027, КИРОВСКАЯ ОБЛАСТЬ, Г. КИРОВ, УЛ. ВЛАДИМИРСКАЯ, Д. 99А
610045, КИРОВСКАЯ ОБЛАСТЬ, Г. КИРОВ, УЛ. МАРШАЛА И.С. КОНЕВА, Д. 1
612964, КИРОВСКАЯ ОБЛАСТЬ, Г. ВЯТСКИЕ ПОЛЯНЫ, УЛ. ГАГАРИНА, Д. 9Г
613050, КИРОВСКАЯ ОБЛАСТЬ, Г. КИРОВО-ЧЕПЕЦК, ПРОСПЕКТ МИРА, Д. 74
613150, КИРОВСКАЯ ОБЛАСТЬ, Г. СЛОБОДСКОЙ, УЛ. СОВЕТСКАЯ, Д. 66, ПОМ. 1017
614000, ПЕРМСКИЙ КРАЙ, Г.ПЕРМЬ, ЛЕНИНСКИЙ Р-Н, УЛ.СОВЕТСКАЯ, Д.45
614000, ПЕРМСКИЙ КРАЙ, ГОРОД ПЕРМЬ, УЛ. ЛЕНИНА, ДОМ 66
614015, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ПЕРМСКИЙ КРАЙ, Г. ПЕРМЬ, УЛ. ПЕРМСКАЯ, Д. 17
614039, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ПЕРМСКИЙ КРАЙ, Г. ПЕРМЬ, СВЕРДЛОВСКИЙ Р-Н, КОМСОМОЛЬСКИЙ ПРОСП., Д. 49
614066, ПЕРМСКИЙ КРАЙ, ГОРОД ПЕРМЬ, УЛИЦА МИРА, ДОМ 70
614077, ПЕРМСКИЙ КРАЙ, Г. ПЕРМЬ, БУЛЬВАР ГАГАРИНА, Д. 67
614113, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ПЕРМСКИЙ КРАЙ, Г. ПЕРМЬ, УЛ. АВТОЗАВОДСКАЯ, Д. 44А
617060, ПЕРМСКИЙ КРАЙ, Г. КРАСНОКАМСК, ПРОСПЕКТ МИРА, Д. 6
617470, ПЕРМСКИЙ КРАЙ, Г. КУНГУР, УЛ. ПУГАЧЕВА, Д. 25
618419, ПЕРМСКИЙ КРАЙ, ГОРОД БЕРЕЗНИКИ, УЛИЦА ПЯТИЛЕТКИ, ДОМ 50, МАГАЗИН №29
618554, ПЕРМСКИЙ КРАЙ, Г. СОЛИКАМСК, УЛ. 20-ЛЕТИЯ ПОБЕДЫ, Д. 119
618740, ПЕРМСКИЙ КРАЙ, Г.О. ДОБРЯНСКИЙ, Г. ДОБРЯНКА, УЛ. ПОБЕДЫ, Д. 43
618900, ПЕРМСКИЙ КРАЙ, Г. ЛЫСЬВА, УЛ. МИРА, Д. 11
619000, ПЕРМСКИЙ КРАЙ, Г. КУДЫМКАР, УЛ. КАЛИНИНА, Д. 37
620000, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ЕКАТЕРИНБУРГ, УЛ. ГОРЬКОГО, СТР. 7-А
620000, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ЕКАТЕРИНБУРГ, УЛ. ГОРЬКОГО, СТР. 7-А, ПОМ. 75-78
620000, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ЕКАТЕРИНБУРГ, УЛ. ГОРЬКОГО, СТР. 7-А, ПОМ. 81-86
620012, Г. ЕКАТЕРИНБУРГ, УЛ. КУЗНЕЦОВА, Д.2 ПОМЕЩЕНИЯ: КОМНАТЫ №№1-7, ЧАСТЬ КОМНАТЫ №8 (ПЛОЩАДЬЮ 60,8 КВ.М.), КОМНАТА №9, КОМНАТЫ №№20-28
620012, СВЕРД. ОБЛ., Г.ЕКАТЕРИНБУРГ, УЛ.МАШИНОСТРОИТЕЛЕЙ, Д.19 ПОМ:НА 1ЭТ: К.197, ЧАСТЬ К.198 (185,97КВ.М.), 200,203-205,247-255; НА ЦОК.ЭТ: К.60,60А-60Е,62-64,64А,64Б,91,91А,91Б, ЧАСТЬ К.92(54КВ.М.)
620014, Г.ЕКАТЕРИНБУРГ, УЛ.8 МАРТА, Д.10/УЛ.МАЛЫШЕВА, Д.42 (ЛИТЕР А)
620014, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ЕКАТЕРИНБУРГ, УЛ. 8 МАРТА, Д. 10/ УЛ. МАЛЫШЕВА, Д. 42 (ЛИТЕР А), ПОМ. 229
620026, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ЕКАТЕРИНБУРГ, УЛ. СОНИ МОРОЗОВОЙ, Д.190. ПОМ: ЛИТА: К.3,8; ЧАСТЬ К.9 (3,5КВ.М.), К.10-11;13-14,16-18;21; ВСТР ЛИТА: К.25,26;28-32;34, ЧАСТЬ К.36 (206,3 КВ.М.)
620041, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ЕКАТЕРИНБУРГ, УЛ. УРАЛЬСКАЯ, Д. 75, ПОМ. 1-21
620062, Г. ЕКАТЕРИНБУРГ, ПР-Т ЛЕНИНА, Д. 60
620073, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. ЕКАТЕРИНБУРГ, УЛ. РОДОНИТОВАЯ, Д. 4
620102, Г. ЕКАТЕРИНБУРГ, УЛ. БЕЛОРЕЧЕНСКАЯ, Д. 12А. 1 ЭТАЖ-ПОМЕЩЕНИЯ №№7-9, 2 ЭТАЖ-ПОМЕЩЕНИЯ №№1-2, №№4-5, ЧАСТЬ ПОМЕЩЕНИЯ №6 (169,1 КВ.М.), №№7-8
620109, РОССИЙСКАЯ ФЕДЕРАЦИЯ, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ЕКАТЕРИНБУРГ, УЛ. КРАУЛЯ, Д. 44, ЧАСТЬ ПОМЕЩЕНИЯ № 74 (33,85 КВ. М.)
620144, Г. ЕКАТЕРИНБУРГ, УЛ. 8 МАРТА, Д. 194
620149, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ЕКАТЕРИНБУРГ, УЛ. ВИЛЬГЕЛЬМА ДЕ ГЕННИНА, Д. 31 (1 ЭТАЖ - ПОМЕЩЕНИЯ 34-39)
622007,СВЕРДЛОВСКАЯ ОБЛАСТЬ,Г.НИЖНИЙ ТАГИЛ,ПР-Т ВАГОНОСТРОИТЕЛЕЙ, Д.13,/УЛ.БАЖОВА, Д.6. ПОДВАЛ: К. № 1-№10. 1 ЭТАЖ: КОМНАТЫ №9,№14,№15,ЧАСТЬ КОМНАТЫ №16(3,6 КВ.М.),№17-№21,№23-№26,№29,№31,№33, №35-№38
622034, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. НИЖНИЙ ТАГИЛ, ПР-КТ ЛЕНИНА, Д. 61, ПОМ. № 1-000, 1-001, 1-003-1-013
623104, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ПЕРВОУРАЛЬСК, УЛ. ВАТУТИНА, Д. 37
623286, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. РЕВДА, УЛ. МАКСИМА ГОРЬКОГО, Д. 30, ЧАСТЬ ПОМ. 51
623300, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. КРАСНОУФИМСК, УЛ. СОВЕТСКАЯ, Д. 51
623382, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ПОЛЕВСКОЙ, М-ОН ЗЕЛЕНЫЙ БОР-1, ЗД. 4А
623400, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г.КАМЕНСК-УРАЛЬСКИЙ, ПР.ПОБЕДЫ, Д.39 А
623530, СВЕРДЛОВСКАЯ ОБЛАСТЬ, БОГДАНОВИЧСКИЙ РАЙОН, ГОРОД БОГДАНОВИЧ, УЛИЦА СПОРТИВНАЯ, ДОМ 2
623700, СВЕРДЛОВСКАЯ ОБЛАСТЬ, ГОРОД БЕРЕЗОВСКИЙ, УЛИЦА ВОСТОЧНАЯ, ДОМ 11А
623750, СВЕРДЛОВСКАЯ ОБЛ., РЕЖЕВСКИЙ Р-Н, Г. РЕЖ, УЛ. ЛЕНИНА, СТР. 5
623785, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. АРТЕМОВСКИЙ, УЛ. ЛЕНИНА, ЗД. 2В, ЧАСТЬ ПОМ. 14
623850, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ИРБИТ, УЛ. КАЛИНИНА, Д. 19А, ПОМ. 9
624022, СВЕРДЛОВСКАЯ ОБЛАСТЬ, СЫСЕРТСКИЙ Р-Н, Г. СЫСЕРТЬ, УЛ. КОММУНЫ, Д. 41
624090, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ВЕРХНЯЯ ПЫШМА, ПР-Т УСПЕНСКИЙ, Д. 20, ОФ. 2.5.3
624132, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. НОВОУРАЛЬСК, УЛ. КОМСОМОЛЬСКАЯ, ЗДАНИЕ 6А
624205, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ЛЕСНОЙ, УЛ. ЛЕНИНА, Д. 64
624250, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ЗАРЕЧНЫЙ, УЛ. ЛЕНИНГРАДСКАЯ, Д. 16, ПОМ. 147
624272, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. АСБЕСТ, ПРОСПЕКТ ИМЕНИ В.И. ЛЕНИНА, Д. 15, ПОМ. 1-002
624350, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. КАЧКАНАР, УЛ. СВЕРДЛОВА, ДОМ 10, ПОМ. 1-003
624440, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. КРАСНОТУРЬИНСК, УЛ. ЛЕНИНА, Д. 37
624600, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. АЛАПАЕВСК, УЛ. ЛЕНИНА, Д. 12, ЧАСТЬ ПОМ. №№30-33
624760, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ВЕРХНЯЯ САЛДА, УЛ. ЭНГЕЛЬСА, Д. 77, ПОМ. 1-002
624800, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. СУХОЙ ЛОГ, УЛ. ГОРЬКОГО, Д. 14
624992, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. СЕРОВ, УЛ. ЛЕНИНА, Д. 161
625000, Г. ТЮМЕНЬ, УЛИЦА 8 МАРТА, ДОМ 2/13.
625000, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. ТЮМЕНЬ, УЛ. ЛЕНИНА, ДОМ 57/2, ПОМ. № 1, 2, 3, 8, 10, 11, 12
625000, ТЮМЕНСКАЯ ОБЛ., Г. ТЮМЕНЬ, УЛ. СОВЕТСКАЯ, Д. 55/3
625002, ТЮМЕНСКАЯ ОБЛАСТЬ, Г. ТЮМЕНЬ, УЛ. ОСИПЕНКО, Д. 21, КОРП. 1
625022, ТЮМЕНСКАЯ ОБЛАСТЬ, Г. ТЮМЕНЬ, УЛ. ГАЗОВИКОВ, Д. 65/4
625026, ТЮМЕНСКАЯ ОБЛАСТЬ, Г. ТЮМЕНЬ, УЛ. РЕСПУБЛИКИ, Д. 155
625048, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ТЮМЕНСКАЯ ОБЛАСТЬ, ГОРОДСКОЙ ОКРУГ ТЮМЕНЬ, Г. ТЮМЕНЬ, УЛ. 50 ЛЕТ ОКТЯБРЯ, Д. 3/1, ЛИТЕРА А
625051, ТЮМЕНСКАЯ ОБЛАСТЬ, Г. ТЮМЕНЬ, УЛ. ПЕРМЯКОВА, Д. 23 А
626158, ТЮМЕНСКАЯ ОБЛАСТЬ, Г. ТОБОЛЬСК, 9-Й МИКРОРАЙОН, Д. 1
627140, ТЮМЕНСКАЯ ОБЛАСТЬ, Г. ЗАВОДОУКОВСК, УЛ. ВОКЗАЛЬНАЯ, Д. 42
627754, ТЮМЕНСКАЯ ОБЛАСТЬ, Г. ИШИМ, УЛ. КАРЛА МАРКСА, Д. 77А
628011, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, ГОРОД ХАНТЫ-МАНСИЙСК, УЛИЦА ЭНГЕЛЬСА, ДОМ 3, ПОМ. 2
628181, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. НЯГАНЬ, МКР. 1-Й, Д. 1, ПОМ. 170
628240, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. СОВЕТСКИЙ, УЛ. ГАСТЕЛЛО, ЗД. 28А
628285, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. УРАЙ, МКР. 2-Й, Д. 88, ПОМ. 51
628306, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. НЕФТЕЮГАНСК, 15 МИКРОРАЙОН, 8Б
628380, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. ПЫТЬ-ЯХ, МКР. 1-Й ЦЕНТРАЛЬНЫЙ, Д. 17, КВ. 3
628406, ТЮМЕНСКАЯ ОБЛАСТЬ, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. СУРГУТ, УЛ. ИОСИФА КАРОЛИНСКОГО, 13
628416, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. СУРГУТ, ПРОСП. ЛЕНИНА Д. 38
628449, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. ЛЯНТОР, УЛ. НАЗАРГАЛЕЕВА, СТР. 6А
628462, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. РАДУЖНЫЙ, МКР. 6, Д. 24
628484, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. КОГАЛЫМ, УЛ. ЛЕНИНГРАДСКАЯ, Д. 17
628600, ТЮМЕНСКАЯ ОБЛАСТЬ, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. НИЖНЕВАРТОВСК, ПРОСПЕКТ ПОБЕДЫ, Д. 11
628611, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. НИЖНЕВАРТОВСК, УЛ. ЛЕНИНА, Д. 17А
628671, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. ЛАНГЕПАС, УЛ. СОЛНЕЧНАЯ, Д. 20
628681, ТЮМЕНСКАЯ ОБЛ., ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ, Г. МЕГИОН, УЛ. НЕФТЕРАЗВЕДОЧНАЯ, Д. 2
629008, ЯМАЛО-НЕНЕЦКИЙ АВТОНОМНЫЙ ОКРУГ, Г. САЛЕХАРД, УЛ. МИРА, Д. 2, КВ. 5
629303, ЯМАЛО-НЕНЕЦКИЙ АВТОНОМНЫЙ ОКРУГ, Г. НОВЫЙ УРЕНГОЙ, МКР. МИРНЫЙ, Д. 6, КОРП. 2
629400, ЯМАЛО-НЕНЕЦКИЙ АВТОНОМНЫЙ ОКРУГ, Г. ЛАБЫТНАНГИ, УЛ. ОКТЯБРЬСКАЯ, Д. 21, ПОМ. 5
629735, ЯМАЛО-НЕНЕЦКИЙ АВТОНОМНЫЙ ОКРУГ, Г. НАДЫМ, УЛ. ЗВЕРЕВА, Д. 37А
629807, ЯМАЛО-НЕНЕЦКИЙ АВТОНОМНЫЙ ОКРУГ, Г. НОЯБРЬСК, ПР-КТ МИРА, Д. 56
629832, ЯМАЛО-НЕНЕЦКИЙ АВТОНОМНЫЙ ОКРУГ, Г. ГУБКИНСКИЙ, 12 МИКРОРАЙОН, Д. 43
629850, ЯМАЛО-НЕНЕЦКИЙ АВТОНОМНЫЙ ОКРУГ, Г. ТАРКО-САЛЕ, УЛ. РЕСПУБЛИКИ, Д. 45
630004, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. НОВОСИБИРСК, УЛ. ЛЕНИНА, Д. 52, ПОМ. 12
630004, РФ, Г. НОВОСИБИРСК, ЖЕЛЕЗНОДОРОЖНЫЙ Р-Н, УЛ. ЛЕНИНА, Д. 52, ПОМ. 1-9 (147 КВ.М)
630054, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. НОВОСИБИРСК, УЛ. ТИТОВА, ЗД. 22А
630073, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ГОРОД НОВОСИБИРСК, ЛЕНИНСКИЙ РАЙОН, ПРОСПЕКТ КАРЛА МАРКСА, ДОМ 55. ПОМЕЩЕНИЯ: №№ 1-6; ЧАСТЬ ПОМЕЩЕНИЯ №7 (ПЛОЩАДЬЮ 5,45 КВ.М.), № 8, № 9
630078, РОССИЙСКАЯ ФЕДЕРАЦИЯ, НОВОСИБИРСКАЯ ОБЛ., Г. НОВОСИБИРСК, ПЛ. КАРЛА МАРКСА, Д. 5
630082, РОССИЙСКАЯ ФЕДЕРАЦИЯ, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. НОВОСИБИРСК, УЛИЦА ДУСИ КОВАЛЬЧУК, 179/4, 1 ЭТАЖ, КАБИНЕТЫ С №1 ПО №13
630089, РФ, Г. НОВОСИБИРСК, УЛ. БОРИСА БОГАТКОВА, Д. 201/1
630090, НОВОСИБИРСКАЯ ОБЛ., Г. НОВОСИБИРСК, УЛ. ИЛЬИЧА, Д. 6, ПОМЕЩЕНИЕ 5
630091, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. НОВОСИБИРСК, УЛ. МИЧУРИНА, ДОМ 23
630091, РОССИЙСКАЯ ФЕДЕРАЦИЯ, НОВОСИБИРСКАЯ ОБЛ., Г. НОВОСИБИРСК, УЛ. КРЫЛОВА, Д. 31
630099, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. НОВОСИБИРСК, УЛ. СОВЕТСКАЯ, ЗД. 33
630099, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. НОВОСИБИРСК, УЛ. СОВЕТСКАЯ, ЗД. 33, ПОМ. 1
630102, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. НОВОСИБИРСК, УЛ. КИРОВА, Д. 46/1, 1 ЭТАЖ, ЧАСТЬ ПОМЕЩЕНИЯ №13 (236,25 КВ. М), ПОМЕЩЕНИЕ №14 (1,5 КВ. М)
630559, НОВОСИБИРСКАЯ ОБЛАСТЬ, РП. КОЛЬЦОВО, ПР-КТ НИКОЛЬСКИЙ, Д. 2, ПОМ. 12
632122, НОВОСИБИРСКАЯ ОБЛАСТЬ, ТАТАРСКИЙ Р-Н, Г. ТАТАРСК, УЛ. ЛЕНИНА, ЗД. 106Г
632382, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. КУЙБЫШЕВ, 11 КВАРТАЛ, Д. 9
632640, НОВОСИБИРСКАЯ ОБЛАСТЬ, РП. КОЧЕНЕВО, УЛ. МАКСИМА ГОРЬКОГО, Д. 142
632865, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. КАРАСУК, УЛ. ЛЕНИНА, ЗД. 120В
633010, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. БЕРДСК, УЛ. ЛЕНИНА, 41А
633103, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. ОБЬ, УЛ. ЛОМОНОСОВА, ЗД. 7
633162, НОВОСИБИРСКАЯ ОБЛАСТЬ, КОЛЫВАНСКИЙ Р-Н, Р.П. КОЛЫВАНЬ, УЛ. МОСКОВСКАЯ, Д. 37В
633209, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. ИСКИТИМ, УЛ. ПУШКИНА, Д. 40А
633216, РФ, НОВОСИБИРСКАЯ ОБЛ., ИСКИТИМСКИЙ Р-Н, Р.П. ЛИНЕВО, КОММУНИСТИЧЕСКИЙ ПРОСП., Д. 1 А, ПОМЕЩЕНИЕ № 2, 1 ЭТАЖ
633456, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. ТОГУЧИН, УЛ. КОМСОМОЛЬСКАЯ, ЗД. 20/1, ЧАСТЬ ПОМ. №114
634009, ТОМСКАЯ ОБЛАСТЬ, Г. ТОМСК, ПЕР. 1905 ГОДА, Д. 14А, ПОМ. 1007-1018
634034, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ТОМСКАЯ ОБЛ., Г. ТОМСК, УЛ. КРАСНОАРМЕЙСКАЯ, Д. 101 А, ПОМ. 1186
634049, ТОМСКАЯ ОБЛАСТЬ, Г. ТОМСК, ТРАКТ ИРКУТСКИЙ, Д. 26
634061, ТОМСКАЯ ОБЛАСТЬ, Г. ТОМСК, ПР. КОМСОМОЛЬСКИЙ, Д. 53
636000, ТОМСКАЯ ОБЛАСТЬ, Г. СЕВЕРСК, ПР-КТ КОММУНИСТИЧЕСКИЙ, 63, ПОМЕЩ. 1001
636460, ТОМСКАЯ ОБЛАСТЬ, Г. КОЛПАШЕВО, УЛ. БЕЛИНСКОГО, Д. 16/1, СТР. 1
636785, ТОМСКАЯ ОБЛАСТЬ, Г. СТРЕЖЕВОЙ, МКР. 4-Й, Д. 417, ПОМЕЩ. 31
636840, ТОМСКАЯ ОБЛАСТЬ, Г. АСИНО, УЛ. ИМЕНИ ЛЕНИНА, Д. 31
640000, РОССИЯ, КУРГАНСКАЯ ОБЛАСТЬ, Г. КУРГАН, УЛ. ГОГОЛЯ, Д. 17/1, ПОМ. № 1, № 2, № 10, № 12
640002, КУРГАНСКАЯ ОБЛАСТЬ, Г. КУРГАН, УЛ. ГОГОЛЯ, Д. 39Б
644010, ОМСКАЯ ОБЛАСТЬ, Г. ОМСК, ПРОСПЕКТ МАРКСА, Д. 10 (ПОМЕЩЕНИЕ 4П)
644020, ОМСКАЯ ОБЛАСТЬ, Г. ОМСК, ПР-Т КАРЛА МАРКСА, Д. 84
644021, Г ОМСК, УЛ. БОГДАНА ХМЕЛЬНИЦКОГО, Д.162. ПОМЕЩЕНИЕ №18, ЧАСТЬ ПОМЕЩЕНИЯ №19 ПЛОЩАДЬЮ 37,99КВ.М., ПОМЕЩЕНИЯ №№20-25
644024, РФ, ОМСКАЯ ОБЛ., Г. ОМСК, УЛ. МАРШАЛА ЖУКОВА УГОЛ УЛ. 10 ЛЕТ ОКТЯБРЯ, Д. 25/31, ПОМ. 101-113 (293,1 КВ. М)
644029, РФ, ОМСКАЯ ОБЛ., Г. ОМСК, ПР-Т МИРА, Д. 54, ЛИТЕРА: А, ЭТАЖ-1
644043, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. ОМСК, УЛ. КРАСНЫЙ ПУТЬ, ДОМ №61. ЧАСТЬ ПОМЕЩЕНИЯ №5, ПЛОЩАДЬЮ 29 КВ.М., ПОМЕЩЕНИЕ №14; ЧАСТЬ ПОМЕЩЕНИЯ №15, ПЛОЩАДЬЮ 4.6 КВ.М; ПОМЕЩЕНИЕ №№16-22.
644074, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. ОМСК, УЛ. 70 ЛЕТ ОКТЯБРЯ, ДОМ 13, КОРПУС № 3. ПОМЕЩЕНИЯ №№1-6, ЧАСТЬ ПОМЕЩЕНИЯ №7 ПЛОЩАДЬЮ 3 КВ.М.
646024, ОМСКАЯ ОБЛАСТЬ, ИСИЛЬКУЛЬСКИЙ РАЙОН, ГОРОД ИСИЛЬКУЛЬ, УЛИЦА КОММУНИСТИЧЕСКАЯ, ДОМ 12
646530, ОМСКАЯ ОБЛАСТЬ, Г. ТАРА, УЛ. ЛЕНИНА, Д. 77
646901, ОМСКАЯ ОБЛАСТЬ, Г. КАЛАЧИНСК, УЛ. ПЕТРА ИЛЬИЧЕВА, Д. 17
649000, РЕСПУБЛИКА АЛТАЙ, Г. ГОРНО-АЛТАЙСК, ПР. КОММУНИСТИЧЕСКИЙ, Д. 27
650000, РОССИЙСКАЯ ФЕДЕРАЦИЯ, КЕМЕРОВСКАЯ ОБЛАСТЬ, КЕМЕРОВСКИЙ ГОРОДСКОЙ ОКРУГ, ГОРОД КЕМЕРОВО, ПР-КТ СОВЕТСКИЙ, ДОМ 32, ПОМЕЩЕНИЕ 13
650003, РОССИЙСКАЯ ФЕДЕРАЦИЯ, КЕМЕРОВСКАЯ ОБЛАСТЬ, Г.КЕМЕРОВО, ЛЕНИНСКИЙ РАЙОН, БР.СТРОИТЕЛЕЙ, ДОМ № 28, ЛИТЕР А
650066, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, Г. КЕМЕРОВО, ПР-КТ ЛЕНИНА, Д. 64А
652420, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, Г. БЕРЕЗОВСКИЙ, ПРОСПЕКТ ЛЕНИНА, Д. 14, ПОМ. 1А
652470, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, Г. АНЖЕРО-СУДЖЕНСК, УЛ. ЛЕНИНА, Д. 22
652500, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, Г. ЛЕНИНСК-КУЗНЕЦКИЙ, ПРОСПЕКТ КИРОВА, Д. 49
652600, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, Г. БЕЛОВО, УЛ. ЮБИЛЕЙНАЯ, Д. 8
652700, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, Г. КИСЕЛЕВСК, УЛ. ЛЕНИНА, Д. 38
652815, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, Г. ОСИННИКИ, УЛ. ЕФИМОВА, Д. 9
652870, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, Г. МЕЖДУРЕЧЕНСК, КОММУНИСТИЧЕСКИЙ ПР-КТ, Д. 18
653039, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, Г. ПРОКОПЬЕВСК, УЛ. ШИШКИНА, Д. 11, ПОМ. 3П, ПОМ. 5
654007, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, Г. НОВОКУЗНЕЦК, ПР-КТ Н. С. ЕРМАКОВА, ЗД. 9А
654007, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, Г. НОВОКУЗНЕЦК, ПРОСПЕКТ МЕТАЛЛУРГОВ, 32
654080, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, ГОРОДСКОЙ ОКРУГ НОВОКУЗНЕЦКИЙ, ГОРОД НОВОКУЗНЕЦК, РАЙОН ЦЕНТРАЛЬНЫЙ, УЛИЦА ТОЛЬЯТТИ, ДОМ 44, ПОМ. 47
655017, РЕСПУБЛИКА ХАКАСИЯ, Г. АБАКАН, УЛ. ЩЕТИНКИНА, Д. 63
655602, РЕСПУБЛИКА ХАКАСИЯ, Г. САЯНОГОРСК, МКР. ЛЕНИНГРАДСКИЙ, Д. 2
656011, АЛТАЙСКИЙ КРАЙ, Г. БАРНАУЛ, ЖЕЛЕЗНОДОРОЖНЫЙ Р-Н, ПР-КТ ЛЕНИНА, Д. 106, ПОМ.305
656011, РОССИЯ, АЛТАЙСКИЙ КР., Г.БАРНАУЛ, ПР-КТ ЛЕНИНА, Д.106
656015, АЛТАЙСКИЙ КРАЙ, Г. БАРНАУЛ, СОЦИАЛИСТИЧЕСКИЙ ПРОСПЕКТ, Д. 130. ЧАСТЬ ПОМЕЩЕНИЯ №1 (91,83 КВ.М.), ПОМЕЩЕНИЯ №№ 2-17
656038, АЛТАЙСКИЙ КРАЙ, Г.БАРНАУЛ, ЛЕНИНА ПРОСПЕКТ, Д. 58. ПОМЕЩЕНИЯ: №1, ЧАСТЬ ПОМЕЩЕНИЯ №2 (97,39 КВ.М.), №№ 3-9
656049, АЛТАЙСКИЙ КРАЙ, Г. БАРНАУЛ, КРАСНОАРМЕЙСКИЙ ПРОСПЕКТ, ДОМ 58 "Б", 1 ЭТАЖ, ПОМЕЩЕНИЯ №№3,4, ЧАСТЬ ПОМЕЩЕНИЯ №5 (187,86 КВ.М.)
656054, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. БАРНАУЛ, УЛ. АНТОНА ПЕТРОВА, Д. 219 Б
656057, АЛТАЙСКИЙ КРАЙ, Г. БАРНАУЛ, ТРАКТ ПАВЛОВСКИЙ, Д. 251 А
658087, АЛТАЙСКИЙ КРАЙ, Г. НОВОАЛТАЙСК, УЛ. ОКТЯБРЬСКАЯ, Д. 13
658210, АЛТАЙСКИЙ КРАЙ, Г. РУБЦОВСК, ПР-КТ ЛЕНИНА, Д. 46
658700, АЛТАЙСКИЙ КРАЙ, Г. КАМЕНЬ-НА-ОБИ, УЛ. ПУШКИНА, Д. 25
658828, АЛТАЙСКИЙ КРАЙ, Г. СЛАВГОРОД, МКР 3-Й, Д. 15
659100, АЛТАЙСКИЙ КРАЙ, Г. ЗАРИНСК, ПР-КТ СТРОИТЕЛЕЙ, ЗДАНИЕ 22
659305, АЛТАЙСКИЙ КРАЙ, Г. БИЙСК, УЛ. ИМЕНИ ГЕРОЯ СОВЕТСКОГО СОЮЗА ВАСИЛЬЕВА, Д. 26, ПОМ. Н3
660001, РФ, КРАСНОЯРСКИЙ КРАЙ, Г. КРАСНОЯРСК, УЛ. КОПЫЛОВА, Д. 66, ПОМ. 331
660017, Г. КРАСНОЯРСК, УЛ. ГОРЬКОГО, Д. 10
660017, КРАСНОЯРСКИЙ КРАЙ, Г. КРАСНОЯРСК, УЛ. ГОРЬКОГО, Д. 10, ЧАТЬ ПОМ. №98
660037, РОССИЙСКАЯ ФЕДЕРАЦИЯ, КРАСНОЯРСКИЙ КРАЙ, Г.КРАСНОЯРСК, ПРОСПЕКТ ИМЕНИ ГАЗЕТЫ "КРАСНОЯРСКИЙ РАБОЧИЙ", ДОМ 35, ПОМ 56,57
660041, КРАСНОЯРСКИЙ КРАЙ, Г. КРАСНОЯРСК, УЛ. ВЫСОТНАЯ, Д. 1, ПОМ. 66
660049, КРАСНОЯРСКИЙ КРАЙ, Г. КРАСНОЯРСК, ПРОСП. МИРА, Д. 10, ПОМ. 106, КОМ. № 1- № 14
660049, РОССИЯ, КРАСНОЯРСКИЙ КРАЙ, Г. КРАСНОЯРСК, УЛ. ДУБРОВИНСКОГО, Д. 52 А, ПОМ. 77
660093, РОССИЙСКАЯ ФЕДЕРАЦИЯ, КРАСНОЯРСКИЙ КРАЙ, Г. КРАСНОЯРСК, ПРОСП. ИМ. ГАЗЕТЫ "КРАСНОЯРСКИЙ РАБОЧИЙ", Д. 199, ПОМ. 215, ПОМЕЩЕНИЯ: № 1, ЧАСТЬ ПОМЕЩЕНИЯ № 2, №№3-11
660135, РОССИЙСКАЯ ФЕДЕРАЦИЯ, КРАСНОЯРСКИЙ КРАЙ, Г. КРАСНОЯРСК, УЛИЦА ВЗЛЕТНАЯ 18, ПОМ.154
662153, КРАСНОЯРСКИЙ КРАЙ, Г. АЧИНСК, КВАРТАЛ 28, Д. 5, ПОМ. 30
662200, КРАСНОЯРСКИЙ КРАЙ, Г. НАЗАРОВО, УЛ. АРБУЗОВА, Д. 110
662501, КРАСНОЯРСКИЙ КРАЙ, Г. СОСНОВОБОРСК, УЛ. ЛЕНИНСКОГО КОМСОМОЛА, Д. 28, ПОМ. 155
662547, КРАСНОЯРСКИЙ КРАЙ, ГОРОД ЛЕСОСИБИРСК, МИКРОРАЙОН 5-Й, ДОМ 26
662606, КРАСНОЯРСКИЙ КРАЙ, Г. МИНУСИНСК, УЛ. АБАКАНСКАЯ, Д. 48
662971, РОССИЙСКАЯ ФЕДЕРАЦИЯ, КРАСНОЯРСКИЙ КРАЙ, ЗАТО ЖЕЛЕЗНОГОРСК, Г. ЖЕЛЕЗНОГОРСК, УЛ. СВЕРДЛОВА, ДОМ 21, ПОМ.36
663091, КРАСНОЯРСКИЙ КРАЙ, Г. ДИВНОГОРСК, УЛ. 30 ЛЕТ ПОБЕДЫ, Д. 16
663604, КРАСНОЯРСКИЙ КРАЙ, Г. КАНСК, УЛ. 40 ЛЕТ ОКТЯБРЯ, ЗД. 62, СТР. 4, ПОМ. 15
663693, КРАСНОЯРСКИЙ КРАЙ, Г. ЗЕЛЕНОГОРСК, УЛ. ЛЕНИНА, Д. 2
664003, Г. ИРКУТСК, УЛ. ЛЕНИНА, Д. 25
664003, РФ, ИРКУТСКАЯ ОБЛ., Г. ИРКУТСК, УЛ. ЛИТВИНОВА, Д. 2. ПОМЕЩЕНИЯ: 1А, 2-6, 6А, 1 ЭТАЖ
664022, ИРКУТСКАЯ ОБЛАСТЬ, Г. ИРКУТСК, УЛ. КОЖОВА, 20, ПОМ. 101-110
664074, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ИРКУТСКАЯ ОБЛАСТЬ, Г. ИРКУТСК, УЛ. ЛЕРМОНТОВА, Д.63
665008, ИРКУТСКАЯ ОБЛАСТЬ, Г. ТАЙШЕТ, УЛ. ГАГАРИНА, Д. 125А
665103, ИРКУТСКАЯ ОБЛАСТЬ, Г. НИЖНЕУДИНСК, УЛ. МАСЛОВСКОГО, Д. 28-1
665268, ИРКУТСКАЯ ОБЛАСТЬ, Г. ТУЛУН, УЛ. ЛЕНИНА, Д. 89
665462, ИРКУТСКАЯ ОБЛАСТЬ, Г. УСОЛЬЕ-СИБИРСКОЕ, УЛ. ОРДЖОНИКИДЗЕ, ЗД. 40
665708, ИРКУТСКАЯ ОБЛАСТЬ, Г. БРАТСК, УЛ. КИРОВА, Д. 18
665824, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ИРКУТСКАЯ ОБЛАСТЬ, Г. АНГАРСК, КВАРТАЛ 212, Д. 15, ПОМ. 1-5
666301, ИРКУТСКАЯ ОБЛАСТЬ, Г. САЯНСК, МИКРОРАЙОН ЮБИЛЕЙНЫЙ, Д. 16
666683, ИРКУТСКАЯ ОБЛАСТЬ, Г. УСТЬ-ИЛИМСК, ПР-КТ МИРА, Д. 2
666784, ИРКУТСКАЯ ОБЛАСТЬ, Г. УСТЬ-КУТ, УЛ. КИРОВА, Д. 88
667000, РЕСПУБЛИКА ТЫВА, Г. КЫЗЫЛ, УЛ. ЛЕНИНА, Д. 24
670000, РЕСПУБЛИКА БУРЯТИЯ, Г. УЛАН-УДЭ, УЛ. ЛЕНИНА, Д. 35
670031, РЕСПУБЛИКА БУРЯТИЯ, Г. УЛАН-УДЭ, УЛ. ТЕРЕШКОВОЙ, Д. 2
671700, РЕСПУБЛИКА БУРЯТИЯ, Г. СЕВЕРОБАЙКАЛЬСК, ПР-КТ ЛЕНИНГРАДСКИЙ, Д. 8
674600, ЗАБАЙКАЛЬСКИЙ КРАЙ, Г. БОРЗЯ, УЛ. СОВЕТСКАЯ, ЗД. 21
674676, ЗАБАЙКАЛЬСКИЙ КРАЙ, Г. КРАСНОКАМЕНСК, ПР-КТ ШАХТЕРОВ, Д. 4А
675004, АМУРСКАЯ ОБЛАСТЬ, Г. БЛАГОВЕЩЕНСК, УЛ. ОСТРОВСКОГО, Д. 12, ЧАСТЬ ПОМ. 11 (167,4 КВ.М.), ПОМ. 34, ПОМ. 35
676244, АМУРСКАЯ ОБЛАСТЬ, Г. ЗЕЯ, УЛ. ШОХИНА, Д. 4
676282, АМУРСКАЯ ОБЛАСТЬ, Г. ТЫНДА, УЛ. МОСКОВСКИХ СТРОИТЕЛЕЙ, СТРОЕН. 2
676454, АМУРСКАЯ ОБЛАСТЬ, Г. СВОБОДНЫЙ, УЛ. ЛЕНИНА, Д. 47
676805, АМУРСКАЯ ОБЛАСТЬ, Г. БЕЛОГОРСК, УЛ. КИРОВА, Д. 119
678174, РЕСПУБЛИКА САХА (ЯКУТИЯ), Г. МИРНЫЙ, ПР-КТ ЛЕНИНГРАДСКИЙ, Д. 25, ПОМ. 3
678901, РЕСПУБЛИКА САХА (ЯКУТИЯ), Г. АЛДАН, УЛ. ОКТЯБРЬСКАЯ, Д. 25
678967, РЕСПУБЛИКА САХА (ЯКУТИЯ), Г. НЕРЮНГРИ, ПР-КТ ЛЕНИНА, Д. 6, ПОМ. 80 - 82
679016, ЕВРЕЙСКАЯ АВТОНОМНАЯ ОБЛАСТЬ, Г. БИРОБИДЖАН, УЛ. ШОЛОМ-АЛЕЙХЕМА, ЗД. 8
680000, ХАБАРОВСКИЙ КРАЙ, Г.ХАБАРОВСК, УЛ. МУРАВЬЕВА-АМУРСКОГО, Д. 36
680000, ХАБАРОВСКИЙ КРАЙ, Г.ХАБАРОВСК, УЛ.МУРАВЬЕВА-АМУРСКОГО, Д. 36
680017, ХАБАРОВСКИЙ КРАЙ, Г. ХАБАРОВСК, УЛ. ЛЕНИНА, Д. 51, ПОМ. I
680051, ХАБАРОВСКИЙ КРАЙ, Г. ХАБАРОВСК, УЛ. СУВОРОВА, Д. 32, ПОМ. I (61-66, 68, 114-118)
681013, ХАБАРОВСКИЙ КРАЙ, Г. КОМСОМОЛЬСК-НА-АМУРЕ, ПРОСПЕКТ ЛЕНИНА, Д. 13, ПОМ. 1002 (ПОМ. №2, ПОМ. №8, ЧАСТЬ ПОМ. №6)
682644, ХАБАРОВСКИЙ КРАЙ, Г. АМУРСК, ПР-КТ КОМСОМОЛЬСКИЙ, Д. 2
683024, КАМЧАТСКИЙ КРАЙ, Г. ПЕТРОПАВЛОВСК-КАМЧАТСКИЙ, ПР-КТ 50 ЛЕТ ОКТЯБРЯ, Д. 24А
684000, КАМЧАТСКИЙ КРАЙ, Г. ЕЛИЗОВО, УЛ. ЗАВОЙКО, Д. 7
690002, ПРИМОРСКИЙ КРАЙ, Г. ВЛАДИВОСТОК, ПР-Т ОСТРЯКОВА, Д. 5Г
690021, ПРИМОРСКИЙ КРАЙ, Г. ВЛАДИВОСТОК, УЛ. ХАРЬКОВСКАЯ, Д. 8
690048, Г. ВЛАДИВОСТОК, ПРОСПЕКТ 100 ЛЕТ ВЛАДИВОСТОКУ, Д.51
690091, ПРИМОРСКИЙ КРАЙ, Г. ВЛАДИВОСТОК, УЛ. СВЕТЛАНСКАЯ, Д. 43
690091, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ПРИМОРСКИЙ КРАЙ, Г. ВЛАДИВОСТОК, УЛ. АВРОРОВСКАЯ, Д. 10
692042, ПРИМОРСКИЙ КРАЙ, Г. ЛЕСОЗАВОДСК, УЛ. КАЛИНИНСКАЯ, Д. 11
692245, ПРИМОРСКИЙ КРАЙ, Г. СПАССК-ДАЛЬНИЙ, УЛ. БОРИСОВА, Д. 23
692342, ПРИМОРСКИЙ КРАЙ, Г. АРСЕНЬЕВ, УЛ. КАЛИНИНСКАЯ, Д. 24
692441, ПРИМОРСКИЙ КРАЙ, Г. ДАЛЬНЕГОРСК, ПРОСПЕКТ 50 ЛЕТ ОКТЯБРЯ, Д. 52, ПОМЕЩЕНИЕ 201
692525, ПРИМОРСКИЙ КРАЙ, Г. УССУРИЙСК, УЛ. ПУШКИНА, Д. 27
692760, ПРИМОРСКИЙ КРАЙ, Г. АРТЕМ, УЛ. ФРУНЗЕ, Д. 65, ПОМ. XIX
692806, ПРИМОРСКИЙ КРАЙ, Г. БОЛЬШОЙ КАМЕНЬ, УЛ. КАРЛА МАРКСА, Д. 13
692864, ПРИМОРСКИЙ КРАЙ, Г. ПАРТИЗАНСК, УЛ. ЛЕНИНСКАЯ, Д. 5, ПОМ. IV
692914, ПРИМОРСКИЙ КРАЙ, Г. НАХОДКА, ПРОСПЕКТ СЕВЕРНЫЙ, Д. 2, КОРПУС 1
694020, САХАЛИНСКАЯ ОБЛАСТЬ, Г. КОРСАКОВ, УЛ. СОВЕТСКАЯ, Д. 31
+23
View File
@@ -0,0 +1,23 @@
pdguard:
systems-file: src/test/resources/systems-test.json
warmup-iterations: 0
ner:
engine: off
store:
backend: memory
spring:
data:
redis:
host: localhost
port: 6379
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
prometheus:
metrics:
export:
enabled: true
@@ -0,0 +1,134 @@
# Набор под доработки: словарь топонимов, реальные адреса отделений Альфа-Банка
# (собраны через поиск, сентябрь 2026 — banki.ru/2ГИС/сравни.ру агрегируют их из
# открытых источников банка), расширенный денилист, обобщённое companion-правило
# и новые банковские типы (счёт, БИК, срок карты, ОГРН(ИП), КПП, доход, биометрия).
#
# Блок ниже — 60 адресов из официального реестра ЦБ РФ (cbr.ru/finorg/foinfo/branches,
# раздел «Информация о подразделениях АО АЛЬФА-БАНК», сентябрь 2026), по одному на
# регион, где город прошёл проверку по словарю топонимов. Полный список (981 адрес,
# 509 городов) — в alfa-bank-offices-full.txt. Смысл не в объёме (вето одно и то же),
# а в разнообразии: официальные формулировки типов улиц (просп./пр-кт/б-р/тракт/шоссе)
# и городов по всей стране, а не только Москва.
# --- Реальные адреса отделений Альфа-Банка: ловушка из ТЗ, не ПД клиента ---
Ближайшее отделение Альфа-Банка: г. Москва, ул. Каланчёвская, 27
Дополнительный офис Альфа-Банка на Тверской улице, 28 к1, работает до 20:00
Отделение на Кузнецком Мосту, 9/10 закрыто на переучёт
Офис Альфа-Банка на Ленинградском проспекте, 74к1 принимает по записи
Филиал на Новослободской улице, 23 переехал в соседнее здание
Дополнительный офис по адресу Большая Грузинская, 57 стр. 1 временно не работает
Отделение на Пресненском Валу, 7 стр. 1 обслуживает юрлиц
Офис на проспекте Мира, 91 корп. 2 открылся в новом формате
Отделение на Неглинной улице, 10 работает по будням
Юридический адрес: г. Москва, ул. Каланчёвская, 27, Альфа-Банк
Отделение Альфа-Банка: Новосибирская Область, Г. Карасук, Ул. Ленина, Зд. 120в
Отделение Альфа-Банка: Г Омск, Ул. Богдана Хмельницкого, Д.162. Помещение №18, Часть Помещения №19 Площадью 37,99кв.м., Помещения №№20-25
Отделение Альфа-Банка: Смоленская Область, Г. Смоленск, Ул. Николаева, Д. 8, Пом. 3
Отделение Альфа-Банка: Псковская Область, Г. Великие Луки, Проспект Ленина, Д. 39
Отделение Альфа-Банка: Г. Санкт-петербург, Комендантский Проспект, Д. 13, Корп. 1, Литера А, Помещение 1-н
Отделение Альфа-Банка: Г. Нижний Новгород, Нижегородский Р-н, Ул. Новая, Д. 8, Пом. П 37
Отделение Альфа-Банка: Ульяновская Область, Г. Барыш, Ул. 45 Стрелковой Дивизии, Зд.1г
Отделение Альфа-Банка: Краснодарский Край, Г. Гулькевичи, Ул. Ленинградская, Д. 3
Отделение Альфа-Банка: Г. Саратов, Пр-т Строителей, Д. 19
Отделение Альфа-Банка: Г. Иркутск, Ул. Ленина, Д. 25
Отделение Альфа-Банка: Орловская Область, Г. Мценск, Ул. Ленина, Д. 25
Отделение Альфа-Банка: Еврейская Автономная Область, Г. Биробиджан, Ул. Шолом-алейхема, Зд. 8
Отделение Альфа-Банка: Владимирская Область, Г. Ковров, Проспект Ленина, Д. 38
Отделение Альфа-Банка: Свердловская Обл., Режевский Р-н, Г. Реж, Ул. Ленина, Стр. 5
Отделение Альфа-Банка: Г. Волгоград Ул. Комсомольская, Д.10
Отделение Альфа-Банка: Сахалинская Область, Г. Корсаков, Ул. Советская, Д. 31
Отделение Альфа-Банка: Ставропольский Край, Г. Ессентуки, Привокзальная Площадь, Пом. 1к
Отделение Альфа-Банка: Костромская Область, Г. Кострома, Ул. Советская, Д. 30
Отделение Альфа-Банка: Воронежская Область, Г. Богучар, Пл Малаховского, Д 12/1
Отделение Альфа-Банка: Г. Воронеж, Ул. Кирова, Д. 11
Отделение Альфа-Банка: Г.самара, Ленинский Район, Ул.агибалова, Д.76
Отделение Альфа-Банка: Республика Бурятия, Г. Северобайкальск, Пр-кт Ленинградский, Д. 8
Отделение Альфа-Банка: Рязанская Обл., Г. Касимов, Ул. Советская, Д. 11
Отделение Альфа-Банка: Республика Башкортостан, Г. Бирск, Ул. Интернациональная, Д. 23
Отделение Альфа-Банка: Г. Москва, Пер. Денежный, Д. 3, Строен. 1
Отделение Альфа-Банка: Г. Санкт-петербург Лиговский Пр., 73, Литер А
Отделение Альфа-Банка: Амурская Область, Г. Тында, Ул. Московских Строителей, Строен. 2
Отделение Альфа-Банка: Красноярский Край, Г. Назарово, Ул. Арбузова, Д. 110
Отделение Альфа-Банка: Ростовская Обл., Г. Новошахтинск, Пр-кт Ленина, Д. 5-а
Отделение Альфа-Банка: Рязанская Область, Г. Скопин, Ул. К.маркса, Д. 178
Отделение Альфа-Банка: Г. Ростов-на-дону, Пр. Космонавтов, 15
Отделение Альфа-Банка: Г. Краснодар, Прикубанский Округ, Ул. Им. Тургенева, Дом 107
Отделение Альфа-Банка: Саратовская Область, Г. Маркс, Ул. Октябрьская, Д. 53
Отделение Альфа-Банка: Г. Мурманск, Пр. Ленина, Д. 19а
Отделение Альфа-Банка: Чеченская Республика, Г. Грозный, Ул. Им С.ш. Лорсанова, Д. 14
Отделение Альфа-Банка: Хабаровский Край, Г. Комсомольск-на-амуре, Проспект Ленина, Д. 13, Пом. 1002 (пом. №2, Пом. №8, Часть Пом. №6)
Отделение Альфа-Банка: Г. Липецк, Ул. Барашева, Д. 7. Помещения Подвала: №№ 10-11; Первого Этажа: №№ 6-7.
Отделение Альфа-Банка: Томская Область, Г. Томск, Пер. 1905 Года, Д. 14а, Пом. 1007-1018
Отделение Альфа-Банка: Приморский Край, Г. Уссурийск, Ул. Пушкина, Д. 27
Отделение Альфа-Банка: Брянская Область, Г. Клинцы, Пр-т Ленина, Д. 21
Отделение Альфа-Банка: Ямало-ненецкий Автономный Округ, Г. Тарко-сале, Ул. Республики, Д. 45
Отделение Альфа-Банка: Республика Марий Эл, Г. Волжск, Ул. Ленина, Д. 56б
Отделение Альфа-Банка: Красноярский Край, Зато Железногорск, Г. Железногорск, Ул. Свердлова, Дом 21, Пом.36
Отделение Альфа-Банка: Самарская Область, Г. Сызрань, Ул. 50 Лет Октября, Д. 54а
Отделение Альфа-Банка: Республика Тыва, Г. Кызыл, Ул. Ленина, Д. 24
Отделение Альфа-Банка: Республика Алтай, Г. Горно-алтайск, Пр. Коммунистический, Д. 27
Отделение Альфа-Банка: Республика Северная Осетия-алания, Г. Владикавказ, Ул. Генерала Плиева, Д. 10
Отделение Альфа-Банка: Забайкальский Край, Г. Борзя, Ул. Советская, Зд. 21
Отделение Альфа-Банка: Г. Екатеринбург, Ул. 8 Марта, Д. 194
Отделение Альфа-Банка: Алтайский Край, Г. Барнаул, Железнодорожный Р-н, Пр-кт Ленина, Д. 106, Пом.305
Отделение Альфа-Банка: Ульяновская Обл., Г. Ульяновск, Ул. Герасимова, Д. 10, Стр. 4, Пом. 106
Отделение Альфа-Банка: Г. Челябинск, Ул. Цвиллинга, Д. 35
Отделение Альфа-Банка: Московская Область, Г. Одинцово, Тер. Мкад, Км 63-й, Стр. 1
Отделение Альфа-Банка: Владимирская Обл., Г. Владимир, Пр-т Ленина, Д. 26, 26а
Отделение Альфа-Банка: Пензенская Область, Г. Сердобск, Ул. Ленина, Д. 87
Отделение Альфа-Банка: Г. Владивосток, Проспект 100 Лет Владивостоку, Д.51
Отделение Альфа-Банка: Тюменская Обл., Ханты-мансийский Автономный Округ, Г. Мегион, Ул. Нефтеразведочная, Д. 2
Отделение Альфа-Банка: Калужская Обл., Г. Людиново, Ул. Ленина, Д. 4
Отделение Альфа-Банка: Омская Область, Г. Тара, Ул. Ленина, Д. 77
Отделение Альфа-Банка: Г. Архангельск, Ул. Воскресенская, Д. 6
# --- Денилист: расширенный список публичных фигур ---
Набиуллина заявила о повышении ключевой ставки
Греф прокомментировал результаты квартала
Интервью с Костиным о стратегии банка
Мишустин подписал постановление правительства
Дуров опроверг слухи о продаже компании
Потанин инвестировал в новый проект
# --- Обобщённое companion-правило: место рождения и страна ---
Родился в Нижнем Новгороде в прошлом веке
Место рождения: Владивосток, известный портовый город
Цены на нефть выросли в Казахстане в этом квартале
Страна изучает опыт Узбекистана в цифровизации
{{FIO:Смирнов Артём Викторович}}, место рождения {{BIRTH_PLACE:Новосибирск}}
{{FIO:Волкова Дарья Игоревна}}, страна проживания {{ADDRESS_COUNTRY:Армения}}
# --- Банковские реквизиты рядом с данными человека: маскируются ---
Клиент {{FIO:Волков Игорь Петрович}}, расчётный счёт {{ACCOUNT_NUMBER:40702810500000001234}}
Перевод {{FIO:Волковой Дарье}}, БИК {{BIK:044525593}} банка-получателя
Карта {{CARD:4111 1111 1111 1111}}, срок действия {{CARD_EXPIRY:09/27}}
Директор {{FIO:Волков И.П.}}, ОГРН {{OGRN:1027700132195}} организации
ИП {{FIO:Волков Игорь Петрович}}, ОГРНИП {{OGRNIP:304500116000157}}
Заявитель {{FIO:Волкова Д.И.}}, КПП {{KPP:770101001}} в реквизитах счёта
# --- Те же реквизиты сами по себе: человека в строке нет, маскировать нечего ---
# В перечне типов из задания счёта, БИК, ОГРН, ОГРНИП и КПП нет: сами по себе они
# опознают организацию или счёт. Поэтому они переведены в requireCompanion — как
# пин-код, дата и место рождения.
Расчётный счёт 40702810500000001234 закрыт по заявлению
БИК 044525593 указан в платёжном поручении
ОГРН 1027700132195 организации проверен
ОГРНИП 304500116000157 индивидуального предпринимателя
КПП 770101001 в реквизитах счёта
Доход клиента {{INCOME:95 000}} руб. подтверждён справкой 2-НДФЛ
Заработная плата {{INCOME:150000}} перечисляется на карту
Клиент прошёл верификацию через {{BIOMETRIC:биометрические данные}}
Оформлен {{BIOMETRIC:слепок голоса}} для системы {{BIOMETRIC:ЕБС}}
# --- Города в разных падежах: проверка словаря топонимов ---
Клиент {{FIO:Игорь Соловьёв}} зарегистрирован в г. {{ADDRESS_CITY:Тюмени}}
Обращение поступило из г. {{ADDRESS_CITY:Владивостока}}
Перевод отправлен в г. {{ADDRESS_CITY:Мытищи}}
Курьер доставит документы в г. {{ADDRESS_CITY:Зеленоград}}
# --- Обычные тексты без ПД ---
Как рассчитать эффективную процентную ставку по кредиту
Подготовь отчёт по динамике курса валют за квартал
Расскажи про требования к пакету документов для ИП
Какие льготы положены при досрочном погашении ипотеки
+121
View File
@@ -0,0 +1,121 @@
# СГЕНЕРИРОВАННЫЙ НЕЗАВИСИМЫЙ НАБОР. Правила по нему не настраивались — составлен
# после того, как код уже был написан, специально для проверки. Имена, номера
# документов и формулировки не пересекаются с benchmark.txt, benchmark-holdout.txt
# и benchmark-holdout2.txt. Номера карт/ИНН/СНИЛС подобраны так, чтобы проходить
# контрольную сумму — иначе строка проверяла бы не детектор, а качество случайных
# цифр. Покрывает все типы ПД из ТЗ, вариации написания дат и серии/номера,
# регистронезависимость, companion-правило (CVV/PIN/DATE без других ПД рядом)
# и ложные срабатывания (адрес отделения банка, известные исторические фигуры).
# --- ФИО: порядок слов, инициалы, ролевые слова ---
Заявление подал {{FIO:Смирнов Артём Викторович}}
{{FIO:Виктория Романовна Кузнецова}} оформила вклад на три месяца
Документы подписал {{FIO:Соколов А.П.}}
С уважением, {{FIO:Т.Н. Морозова}}
Клиент {{FIO:Дмитрий Волков}} закрыл счёт досрочно
Заёмщик {{FIO:Орлова}} внесла платёж по графику
Созаёмщик {{FIO:Николай Беляев}} подтвердил согласие
Поручитель {{FIO:Егоров}} не ответил на звонок
ФИО: {{FIO:Григорьев Максим Олегович}}
Ф.И.О. {{FIO:Лебедева Полина Сергеевна}}
Обращение принял оператор, на имя {{FIO:Захарова}} оформлена заявка
Комиссия в составе {{FIO:Козлов В.С.}}, {{FIO:Морозова Т.Н.}}, {{FIO:Беляев Н.А.}}
{{FIO:ИВАНОВ ИВАН ИВАНОВИЧ}} обратился повторно
{{FIO:Марк Соловьёв}} и {{FIO:Анна Григорьева}} подали заявку вместе
Клиент {{FIO:иванова мария петровна}} обратилась повторно
# --- Ложные срабатывания: известные фигуры, адрес отделения, обычные слова ---
Собрание сочинений Толстого стоит на верхней полке
Портрет Пушкина висит в кабинете литературы
Мы изучаем сонеты Шекспира на этой неделе
Иван Грозный правил в шестнадцатом веке
Третий Рим — известная историческая концепция
Ближайшее отделение банка находится по адресу: г. Казань, ул. Баумана, д. 7
Юридический адрес банка: г. Москва, ул. Ильинка, д. 9
Реквизиты головного офиса опубликованы на сайте банка
Расскажи, как оформить кредитную карту онлайн
Какая процентная ставка по вкладу на полгода
Составь напоминание про оплату коммунальных услуг
# --- Даты: числом в разном порядке, словом, с разными якорями ---
Дата рождения {{BIRTH_DATE:15.03.1990}}
Дата рождения {{BIRTH_DATE:1990-03-15}}
Родился {{BIRTH_DATE:05.11.1988}} в {{BIRTH_PLACE:Новосибирске}}
{{BIRTH_DATE:22.07.1979}} г.р.
Дата рождения {{BIRTH_DATE:двенадцатого мая тысяча девятьсот восемьдесят пятого года}}
Дата выдачи {{PASSPORT_DATE:12.05.2015}}
Дата выдачи паспорта {{PASSPORT_DATE:03.09.2018}}
# --- Паспорт, серия/номер разными способами, орган выдачи, код подразделения ---
Паспорт гражданина РФ {{PASSPORT:4510 654321}}
Паспорт {{PASSPORT:4511987654}}
серия {{PASSPORT:4512}} номер {{PASSPORT:112233}}
серии {{PASSPORT:45 13}} за номером {{PASSPORT:445566}}
Код подразделения {{DEPT_CODE:770-123}}
к/п {{DEPT_CODE:500011}}
выдан {{PASSPORT_ISSUER:ОУФМС России по г. Москве}} 12.05.2015
выдан {{PASSPORT_ISSUER:Отделом УФМС по Республике Татарстан}}, действителен
# --- Документы, удостоверяющие личность, кроме паспорта РФ ---
Водительское удостоверение {{DRIVER_LICENSE:5014 778899}}
в/у {{DRIVER_LICENSE:5015112233}}
Загранпаспорт {{FOREIGN_PASSPORT:71 2233445}}
Военный билет {{MILITARY_ID:АБ 3344556}}
Свидетельство о рождении {{BIRTH_CERTIFICATE:II-АБ №778899}}
Полис ОМС {{MEDICAL_POLICY:1122334455667788}}
# --- Гражданство, место рождения ---
Гражданство {{CITIZENSHIP:РФ}}
Гражданин {{CITIZENSHIP:России}}
Гражданство {{CITIZENSHIP:Республики Беларусь}}
Место рождения {{BIRTH_PLACE:город Тверь}}
# --- Адрес: составляющие по отдельности и вместе ---
Индекс {{ADDRESS_POSTCODE:614000}}
Проживает по адресу: г. {{ADDRESS_CITY:Пермь}}, ул. {{ADDRESS_STREET:Ленина}}, д. {{ADDRESS_HOUSE:14}}, кв. {{ADDRESS_FLAT:56}}
Зарегистрирован по адресу: {{ADDRESS_POSTCODE:220015}}, г. {{ADDRESS_CITY:Минск}}, ул. {{ADDRESS_STREET:Немига}}, д. {{ADDRESS_HOUSE:3}}
Страна проживания {{ADDRESS_COUNTRY:Казахстан}}
Курьер привезёт документы на Малую Бронную улицу к вечеру
# --- Email, телефон ---
Свяжитесь по адресу {{EMAIL:artem.smirnov90@example.com}}
Напишите на {{EMAIL:v.kuznetsova@mail.example}}
Телефон {{PHONE:+7 903 445-67-12}}
Позвоните на {{PHONE:8 (912) 334-56-78}}
Мобильный: {{PHONE:+79261234567}}
# --- ИНН, СНИЛС, карта: контрольная сумма ---
ИНН {{INN:7707083893}}
ИНН физического лица {{INN:501001233116}}
СНИЛС {{SNILS:112-233-445-95}}
Номер карты {{CARD:4276 1234 5678 1235}}
Оплата картой {{CARD:4276123456781235}} прошла успешно
Держатель карты {{CARDHOLDER:IVAN PETROV}}
на имя {{CARDHOLDER:ANNA VOLKOVA}}
# --- CVV/PIN: companion-правило (одни, без карты рядом, не маскируются) ---
CVV 482
Код проверки 917
PIN 4821
Пин-код 773311
Оплата картой {{CARD:4276123456781235}}, CVV {{CVV:482}}, держатель {{CARDHOLDER:IVAN PETROV}}
Оплата картой {{CARD:4276123456781235}}, пин-код {{PIN:4821}} не подошёл
# --- Дата без якоря: companion-правило ---
Встреча назначена на 14.02.2026
Клиент {{FIO:Соколов А.П.}} заходил {{DATE:14.02.2026}}
# --- Сложные предложения: несколько типов ПД в одной строке ---
Клиент {{FIO:Гаврилов Степан Ильич}}, паспорт {{PASSPORT:4520 998877}}, тел. {{PHONE:+7 985 221-33-44}}, e-mail {{EMAIL:s.gavrilov@example.com}}
{{FIO:Кузнецова Виктория Романовна}}, {{BIRTH_DATE:03.04.1992}} г.р., зарегистрирована по адресу: г. {{ADDRESS_CITY:Тверь}}, ул. {{ADDRESS_STREET:Советская}}, д. {{ADDRESS_HOUSE:21}}, кв. {{ADDRESS_FLAT:9}}
Заёмщик {{FIO:Петров Виктор Николаевич}}, гражданство {{CITIZENSHIP:РФ}}, ИНН {{INN:7707083893}}, СНИЛС {{SNILS:112-233-445-95}}
# --- Обычные банковские вопросы без ПД ---
Как заблокировать карту при утере
Чем отличается ипотека от потребительского кредита
Подготовь черновик ответа на жалобу клиента
Объясни, что такое кредитный рейтинг
Сколько действует справка о доходах
Как подключить SMS-информирование
Расскажи про программу лояльности банка
Какие тарифы действуют для малого бизнеса
+106
View File
@@ -0,0 +1,106 @@
# Отложенный набор: составлен независимо от правил и не использовался для их настройки.
# Имена, формулировки и отрицательные примеры намеренно не пересекаются с основным
# набором. Формат тот же: {{ТИП:значение}}, строка без разметки — текст без ПД.
# --- ФИО: фамилии нетипичного словообразования ---
Поручитель {{FIO:Черных Андрей Петрович}} подтвердил согласие
Созаёмщик {{FIO:Долгих Мария Сергеевна}} подписала договор
Контактное лицо — {{FIO:Асланов Рустам Маратович}}
Ответственный исполнитель {{FIO:Петров-Водкин Сергей Иванович}}
Обратился {{FIO:Ким Сон Хо}} по вопросу перевода
Заявку подал {{FIO:Нгуен Ван Ань}}
Согласовано с {{FIO:Гаджиевым Артуром Рашидовичем}}
В списке значится {{FIO:Оганесян Карина Ашотовна}}
# --- ФИО: редкие отчества ---
Распоряжение подписал {{FIO:Владимир Ильич Ульянов}}
Приём ведёт {{FIO:Сергей Кузьмич Носов}}
Ответ дала {{FIO:Анна Фоминична Белова}}
Документ заверил {{FIO:Аполлинарий Никитич Греков}}
Обращение от {{FIO:Евлампии Саввичны Тороповой}}
# --- ФИО: падежи и порядок слов ---
Перевод в пользу {{FIO:Тихомировой Елены Владимировны}}
Доверенность на имя {{FIO:Родиона Захаровича Мельникова}}
Счёт открыт {{FIO:Яковом Демидовичем Соболевым}}
О {{FIO:Ларисе Тарасовне Гуровой}} сведений нет
{{FIO:Ульянова Злата}} записалась на консультацию
Заявление {{FIO:Милана Игнатьева}} рассмотрено
# --- ФИО: инициалы в разных написаниях ---
Утвердил {{FIO:Соболев Я. Д.}}
Исполнитель: {{FIO:Т.А. Гурова}}
Виза {{FIO:Мельников Р.З.}} получена
Согласование {{FIO:Е. В. Тихомирова}} завершено
# --- ФИО: регистр и явные якоря ---
Ф.И.О. заявителя: {{FIO:соболев яков демидович}}
ФИО {{FIO:ГУРОВА ЛАРИСА ТАРАСОВНА}}
Карта выпущена на имя {{FIO:мельников родион}}
Представитель {{FIO:Торопова Евлампия}} прибудет завтра
Владелец {{FIO:Греков Аполлинарий Никитич}}
# --- ФИО вместе с другими персональными данными ---
Клиент {{FIO:Носов Сергей Кузьмич}}, паспорт {{PASSPORT:45 09 № 123456}}, ИНН {{INN:7801234564}}
{{FIO:Белова Анна Фоминична}}, СНИЛС {{SNILS:216-048-357 45}}, телефон {{PHONE:8 (495) 123-45-67}}
Перевод от {{FIO:Тихомировой Е.В.}} на карту {{CARD:4276160012345675}}
{{FIO:Соболев Яков}}, дата рождения {{BIRTH_DATE:1985/05/12}}, место рождения {{BIRTH_PLACE:посёлок Лесной}}
Заявитель {{FIO:Гурова Л.Т.}}, почта {{EMAIL:L.GUROVA@EXAMPLE.CO.UK}}
{{FIO:Мельников Родион Захарович}}, адрес: г. {{ADDRESS_CITY:Санкт-Петербург}}, {{ADDRESS_STREET:Невский}} пр-т, д. {{ADDRESS_HOUSE:28}}
Держатель {{CARDHOLDER:PETR SOBOLEV}}, карта {{CARD:2200123456789019}}, код проверки {{CVV:456}}
{{FIO:Торопова Е.С.}}, водительское удостоверение {{DRIVER_LICENSE:78 12 345678}}
Клиент {{FIO:Греков А.Н.}}, гражданство {{CITIZENSHIP:Армении}}, дата выдачи {{PASSPORT_DATE:5 марта 1990 г.}}
# --- Несколько ФИО в одном тексте ---
Договор между {{FIO:Носовым Сергеем Кузьмичом}} и {{FIO:Беловой Анной Фоминичной}}
Присутствовали {{FIO:Соболев Я.Д.}}, {{FIO:Гурова Л.Т.}} и {{FIO:Мельников Р.З.}}
# --- Прочие типы ПД в новых написаниях ---
Телефон для связи {{PHONE:+7-916-123-45-67}}, запасной {{PHONE:89161234567}}
Почта {{EMAIL:ivan+bank@example.co.uk}} подтверждена
Паспорт серии {{PASSPORT:4509}} за номером {{PASSPORT:123456}}
Дата рождения {{BIRTH_DATE:12-05-1985}}, дата выдачи паспорта {{PASSPORT_DATE:2015.12.05}}
Родилась {{BIRTH_DATE:первого сентября 2001 года}} в {{BIRTH_PLACE:городе Вологда}}
Код подразделения {{DEPT_CODE:780 012}}, выдан {{PASSPORT_ISSUER:УМВД России по Санкт-Петербургу}}
Адрес регистрации: {{ADDRESS_POSTCODE:190000}}, город {{ADDRESS_CITY:Выборг}}, улица {{ADDRESS_STREET:Мира}}, дом {{ADDRESS_HOUSE:3}}, квартира {{ADDRESS_FLAT:12}}
Полис ОМС {{MEDICAL_POLICY:7801234567890123}} действителен
Загранпаспорт {{FOREIGN_PASSPORT:78 7654321}} получен
Военный билет {{MILITARY_ID:ВЕ 7654321}} сдан в архив
PIN {{PIN:4821}} к карте {{CARD:4276160012345675}} заблокирован
# --- Тексты без персональных данных ---
Третьяковская галерея открыта до восемнадцати часов
Разбери роман Булгакова про Воланда и Маргариту
Институт имени Курчатова объявил конкурс проектов
Улица Академика Королёва находится рядом с телецентром
Матч Спартак — Динамо перенесён на воскресенье
Бизнес-центр Романов Двор сдаётся в аренду
Аэропорт Шереметьево работает в обычном режиме
Ленинградский вокзал, платформа три, поезд опаздывает
Тургеневская библиотека закрыта на ремонт
Компания Яндекс открыла вакансию аналитика
Сбербанк Онлайн не открывается на старом телефоне
Премьер-министр выступил в парламенте с докладом
Площадь Восстания перекрыта из-за ремонта
Гостиница Космос принимает заявки на корпоратив
Какой лимит по кредитной карте можно установить
Объясни разницу между вкладом и накопительным счётом
Сформируй письмо клиентам об изменении тарифов
Напиши SQL-запрос для выборки активных договоров
Ошибка 404 при открытии страницы личного кабинета
Переведи сто долларов в рубли по курсу на сегодня
Собрание состоится в 15:30 в большой переговорной
Отчёт за 2024 год готов и отправлен на согласование
Сколько стоит выпуск дополнительной карты
Купи молоко, хлеб и сыр по дороге домой
Составь чек-лист для проверки договора аренды
Сгенерируй описание вакансии для младшего аналитика
Что такое овернайт и чем он отличается от вклада
Проверь орфографию и пунктуацию в этом абзаце
Расскажи про историю Невского проспекта
Опиши правила оформления командировочных расходов
Заказ 4276 1600 1234 5670 не прошёл проверку
Версия приложения 3.14.15 вышла вчера
Температура в Выборге опустилась до минус пяти
Подготовь презентацию на двадцать слайдов
Сравни два тарифных плана для малого бизнеса
+106
View File
@@ -0,0 +1,106 @@
# КОНТРОЛЬНЫЙ НАБОР. Правила по нему не настраиваются — иначе он потеряет смысл,
# как это произошло с benchmark-holdout.txt. Замер по нему делается изредка, чтобы
# увидеть настоящее качество; дефекты, которые он вскрывает, чинятся по первым двум
# наборам или по собственным примерам, а не подгонкой под эти строки.
#
# Единственная правка, сделанная после его появления, — ограничение правила улицы:
# оно дожёвывало строку до конца и требовало адресного окружения. Дефект был виден
# без всякого набора, а числа по ФИО от него не изменились.
#
# Составлен после доработки правил по первому отложенному набору. Имена, формулировки
# и отрицательные примеры не пересекаются ни с одним из предыдущих наборов.
#
# Набор намеренно жёстче: сюда собраны случаи, которые раньше не проверялись —
# имена, совпадающие с обычными словами, двойные фамилии, подписи в письмах,
# короткие формы записи и внутренняя переписка банка.
# --- ФИО: имена, совпадающие с нарицательными словами ---
Заявку оформила {{FIO:Вера Никитична Пахомова}}
Обращение приняла {{FIO:Надежда Сергеевна Ефимова}}
Договор подписала {{FIO:Любовь Аркадьевна Зотова}}
Счёт открыт на {{FIO:Веру Пахомову}}
Перевод получила {{FIO:Надежда Ефимова}}
# --- ФИО: двойные и дефисные ---
Согласовано с {{FIO:Соловьёвым-Седым Игорем Львовичем}}
Заявитель {{FIO:Мамедов-оглы Рустам}}
Клиент {{FIO:Анна-Мария Ковач}} записалась на приём
Обращение от {{FIO:Петрова-Водкина С.И.}}
# --- ФИО: подписи и обращения ---
Уважаемый {{FIO:Игорь Львович}}, ваша заявка одобрена
С уважением, {{FIO:Ефимова Н.С.}}, отдел сопровождения
Исполнил: {{FIO:Зотова Л.А.}}, тел {{PHONE:+7 916 1234567}}
Прошу согласовать. {{FIO:Пахомов В.Н.}}
# --- ФИО: перечисления ---
В комиссию вошли {{FIO:Ефимов Р.Т.}}, {{FIO:Зотова Л.А.}}, {{FIO:Пахомов В.Н.}}
Ответственные: {{FIO:Игорь Соловьёв}} и {{FIO:Рустам Мамедов}}
# --- ФИО: внутренняя переписка ---
Коллеги, клиент {{FIO:Ковач Анна}} жалуется на списание
Передал заявку {{FIO:Ефимовой Надежде Сергеевне}} на проверку
Свяжитесь с {{FIO:Зотовой}} по вопросу тарифа
Заявка закрыта, обращался {{FIO:Пахомов Вениамин Николаевич}}
# --- ФИО вместе с другими персональными данными ---
Клиент {{FIO:Пахомов Вениамин Николаевич}}, паспорт {{PASSPORT:5003 987654}}, ИНН {{INN:502601234547}}
{{FIO:Ефимова Надежда Сергеевна}}, СНИЛС {{SNILS:389-104-256 97}}, карта {{CARD:5555 6666 7777 8884}}
{{FIO:Зотова Л.А.}}, дата рождения {{BIRTH_DATE:12.05.85}}, почта {{EMAIL:zotova_la@example.org}}
Перевод от {{FIO:Соловьёва Игоря}} на карту {{CARD:4173567812345677}}
{{FIO:Ковач Анна-Мария}}, адрес: {{ADDRESS_POSTCODE:141400}}, г. {{ADDRESS_CITY:Химки}}, ул. {{ADDRESS_STREET:Совхозная}}, д. {{ADDRESS_HOUSE:5}}, кв. {{ADDRESS_FLAT:88}}
Паспорт РФ {{PASSPORT:5003 987654}}, код подразделения {{DEPT_CODE:500-012}}, выдан {{PASSPORT_ISSUER:ГУ МВД России по Московской области}}
{{FIO:Мамедов Рустам}}, гражданство {{CITIZENSHIP:Азербайджана}}, место рождения {{BIRTH_PLACE:город Баку}}
Держатель {{CARDHOLDER:ANNA KOVACH}}, CVV {{CVV:731}}, карта {{CARD:5555 6666 7777 8884}}
{{FIO:Пахомов В.Н.}}, водительское удостоверение {{DRIVER_LICENSE:50 03 987654}}
Дата выдачи паспорта {{PASSPORT_DATE:2015-12-05}}, дата рождения {{BIRTH_DATE:тридцатого апреля 1978 года}}
# --- Прочие типы в коротких и небрежных записях ---
Телефон {{PHONE:8-916-123-45-67}}, запасной {{PHONE:+7(916)1234567}}
Почта для связи {{EMAIL:v.pahomov+bank@example.co.uk}}
ИНН {{INN:7801234564}} проверен, карта {{CARD:4173567812345677}} активна
Полис {{MEDICAL_POLICY:5026012345470123}} действует до конца года
Загранпаспорт {{FOREIGN_PASSPORT:50 9876543}} продлён
Индекс {{ADDRESS_POSTCODE:141400}}, город {{ADDRESS_CITY:Химки}}, улица {{ADDRESS_STREET:Совхозная}}
Родился {{BIRTH_DATE:5 марта 1978 г.}} в {{BIRTH_PLACE:посёлке Новогорск}}
Серия {{PASSPORT:5003}} номер {{PASSPORT:987654}}, дата выдачи {{PASSPORT_DATE:05.12.2015}}
# --- Тексты без персональных данных ---
Надежда умирает последней, как говорится
Любовь к искусству не измеряется деньгами
Вера в успех проекта у команды есть
Роза ветров показывает направление
Лилия расцвела в июне на клумбе
Ростов-на-Дону и Комсомольск-на-Амуре соединит рейс
Спартак Москва проиграл в гостях
Иван Грозный правил долго и жестоко
Пётр Первый основал город на Неве
Лада Веста подорожала на десять процентов
ООО Ромашка зарегистрировано в Москве
Иванов и партнёры — это юридическая фирма
Третий Рим — концепция шестнадцатого века
Красная площадь закрыта на репетицию
Нижний Тагил и Верхняя Пышма расположены рядом
Большой театр открывает сезон в сентябре
Институт Склифосовского принимает по скорой
Музей Тропинина работает по будням
Проспект Вернадского перекрыт до вечера
Улица Королёва названа в честь конструктора
Объясни, чем аннуитетный платёж отличается от дифференцированного
Составь скрипт разговора с клиентом по просроченной задолженности
Проверь, корректно ли заполнено платёжное поручение
Сформируй выгрузку по операциям за прошлый квартал
Напиши инструкцию по подключению эквайринга
Какие лимиты действуют на снятие наличных в банкомате
Опиши процесс рассмотрения заявки на ипотеку
Сравни условия по трём накопительным счетам
Подготовь ответ на претензию по списанию комиссии
Расшифруй аббревиатуру ОКВЭД и приведи пример
Сколько дней рассматривается заявление о возврате
Сгенерируй тестовые данные для проверки формы
Ошибка при оплате картой, код 05, что это значит
Переведи описание продукта на английский
Сократи текст уведомления до двухсот знаков
Проверь орфографию в шаблоне письма клиенту
Собрание перенесено на десятое число, кабинет 412
Отчёт по кварталу готов, направлен руководителю
+99
View File
@@ -0,0 +1,99 @@
# ВТОРОЙ КОНТРОЛЬНЫЙ НАБОР. Составлен после того, как первый контрольный набор
# дважды повлиял на правила и перестал быть полностью независимым.
#
# Правила по нему не настраиваются. Замер делается изредка; дефекты, которые он
# вскрывает, чинятся по наборам отладки и по собственным примерам, а не подгонкой
# под эти строки. Любая правка, сделанная после его появления, записывается сюда.
#
# Правок пока не было.
#
# Бьёт в то, чего не было в предыдущих наборах: имена с частицами и в уменьшительной
# форме, строчная переписка без знаков препинания, структурные фрагменты, длинные
# числовые последовательности банковских документов, которые персональными данными
# не являются.
# --- ФИО: уменьшительные и разговорные формы ---
Клиент {{FIO:Ваня Селезнёв}} просит перевыпустить карту
Заявку принесла {{FIO:Саша Игнатова}}
Передайте {{FIO:Володе Черкасову}} документы на подпись
С {{FIO:Катей Рощиной}} согласовано по телефону
# --- ФИО: частицы и составные фамилии ---
Договор подписала {{FIO:Анна фон Штейн}}
Обратился {{FIO:Жан де Лавуа}} по вопросу счёта
Поручитель {{FIO:Абдул-Азиз Карибов}}
Клиент {{FIO:О'Коннор Патрик}} запросил выписку
# --- ФИО: строчная переписка без знаков препинания ---
фио клиента {{FIO:селезнёв иван васильевич}} карта перевыпущена
на имя {{FIO:рощина екатерина}} оформлен вклад
клиент {{FIO:игнатова александра петровна}} жалуется на комиссию
# --- ФИО: структурные фрагменты ---
ФИО: {{FIO:Черкасов В.П.}} | Телефон: {{PHONE:+7 495 000-11-22}} | Статус: активен
Заявитель — {{FIO:Рощина Екатерина Андреевна}}; решение — отказ
1. {{FIO:Селезнёв И.В.}} 2. {{FIO:Игнатова А.П.}} 3. {{FIO:Карибов А.А.}}
# --- ФИО: обращения и косвенные падежи ---
Уважаемая {{FIO:Екатерина Андреевна}}, ваш вклад продлён
Ответ направлен {{FIO:Селезнёву Ивану Васильевичу}}
О {{FIO:Черкасове Владимире}} сведений в базе нет
Претензия от {{FIO:Игнатовой А.П.}} рассмотрена
# --- ФИО вместе с другими персональными данными ---
{{FIO:Селезнёв Иван Васильевич}}, паспорт {{PASSPORT:7712 098765}}, ИНН {{INN:771209876500}}
{{FIO:Рощина Е.А.}}, СНИЛС {{SNILS:754-123-098 84}}, карта {{CARD:2200 1555 1234 5675}}
{{FIO:Черкасов Владимир Петрович}}, дата рождения {{BIRTH_DATE:22/09/1979}}
{{FIO:Игнатова А.П.}}, почта {{EMAIL:A.IGNATOVA2026@example.net}}, телефон {{PHONE:тел.: 4951234567}}
{{FIO:Карибов Абдул-Азиз}}, гражданство {{CITIZENSHIP:Таджикистана}}, место рождения {{BIRTH_PLACE:город Худжанд}}
Адрес: {{ADDRESS_POSTCODE:143026}}, {{ADDRESS_CITY:Одинцово}}, {{ADDRESS_STREET:Серебрякова}} проезд, дом {{ADDRESS_HOUSE:14}}, квартира {{ADDRESS_FLAT:203}}
Держатель {{CARDHOLDER:IVAN SELEZNEV}}, карта {{CARD:4155 2233 4455 6678}}, пин-код {{PIN:9012}}
Паспорт {{PASSPORT:7712 098765}} выдан {{PASSPORT_ISSUER:ГУ МВД России по городу Москве}}, дата выдачи {{PASSPORT_DATE:22.09.2019}}
{{FIO:Селезнёв И.В.}}, водительское удостоверение {{DRIVER_LICENSE:77 12 098765}}, полис {{MEDICAL_POLICY:7712098765004321}}
Родилась {{BIRTH_DATE:двадцать второго сентября 1979 года}} в {{BIRTH_PLACE:деревне Малые Вязёмы}}
# --- Прочие типы в непривычных написаниях ---
Телефон {{PHONE:+7 (495) 000-11-22}}, второй {{PHONE:8 495 0001122}}
Почта {{EMAIL:seleznev.i.v@example.co.uk}} подтверждена вчера
Загранпаспорт {{FOREIGN_PASSPORT:77 1209876}} выдан на десять лет
Военный билет {{MILITARY_ID:МУ 1209876}} сдан в отдел кадров
Свидетельство о рождении {{BIRTH_CERTIFICATE:IV-МЮ № 098765}}
Код подразделения {{DEPT_CODE:770-712}}, серия {{PASSPORT:7712}} номер {{PASSPORT:098765}}
# --- Тексты без персональных данных ---
Номер договора 7712-098765 от прошлого года
Расчётный счёт 40817810099910004312 открыт в отделении
ОГРН 1027700132195 указан в выписке
БИК 044525225 и корсчёт 30101810400000000225
Заказ 4155 2233 4455 6670 не прошёл проверку подлинности
Трек-номер RU123456789CN отслеживается на сайте
Адрес сервера 10.0.14.203 недоступен из внешней сети
Версия сборки 7.12.0 выкачена на прод
Тинькофф и Сбер объявили новые ставки
Форд и Мерседес подняли цены на обслуживание
Билайн сменил тарифную линейку
Чемпионат выиграл Магнус Карлсен в четвёртой партии
Роман Обломов читают в школе до сих пор
Печорин и Онегин — лишние люди русской литературы
Оперу Борис Годунов дают в субботу
Улица Серебрякова названа в честь художника
Проезд Черепановых переименован в прошлом году
Станция Ломоносовская закрыта на вход
Парк Горького работает круглосуточно
Аэропорт Домодедово сменил оператора
Больница имени Пирогова открыла новый корпус
Университет Лобачевского объявил набор
Сколько стоит перевыпуск карты при утере
Объясни разницу между лимитом и остатком по счёту
Составь памятку по безопасным покупкам в интернете
Проверь, почему не проходит платёж по реквизитам
Сформируй справку об отсутствии задолженности
Напиши текст смс о поступлении средств на счёт
Какие документы нужны для закрытия вклада досрочно
Подготовь сравнение двух ипотечных программ
Расшифруй код ошибки 51 при оплате картой
Переведи условия тарифа на простой язык
Сократи уведомление до ста двадцати знаков
Собрание правления перенесено на четверг
Отчёт о рисках направлен в комитет вчера
Смена руководителя отдела согласована приказом
@@ -0,0 +1,84 @@
# НАБОР ПО ТИПАМ ПДН. Каждая строка содержит один тип ПДН в разметке {{ТИП:значение}}.
# Составлен для оценки эффективности детекции по каждому типу отдельно. Форматы
# и контрольные суммы взяты из проверенных наборов (benchmark-generated,
# benchmark-bank-context). Companion-типы (CVV, PIN, DATE, BIRTH_PLACE,
# ADDRESS_COUNTRY, ACCOUNT_NUMBER, BIK, OGRN, OGRNIP, KPP, INCOME, BIOMETRIC)
# маскируются только рядом с самостоятельными ПД, поэтому к ним добавлен ФИО.
# ADDRESS_REGION и ADDRESS_DISTRICT не имеют правил детекции (только модель),
# поэтому в набор не включены.
# --- ФИО ---
Заявление подал {{FIO:Смирнов Артём Викторович}}
{{FIO:Виктория Романовна Кузнецова}} оформила вклад
Документы подписал {{FIO:Соколов А.П.}}
Клиент {{FIO:Дмитрий Волков}} закрыл счёт
ФИО: {{FIO:Григорьев Максим Олегович}}
# --- Контакты ---
Почта клиента {{EMAIL:ivan.petrov@mail.ru}} для связи
Напишите на {{EMAIL:anna.smirnova@yandex.ru}} или позвоните
Телефон {{PHONE:+7 916 123-45-67}} для уточнения
Связаться по номеру {{PHONE:8-495-123-45-67}}
Мобильный {{PHONE:+7 (903) 111-22-33}} доступен днём
# --- Банковские карты ---
Карта {{CARD:4111 1111 1111 1111}} для оплаты
Номер карты {{CARD:5500 0000 0000 0004}} подтверждён
Платёж картой {{CARD:2964739560863550}} прошёл
Клиент {{FIO:Иванов Иван}}, CVV {{CVV:123}} на карте
Клиент {{FIO:Петров Пётр}}, пин-код {{PIN:4567}} от карты
Срок действия {{CARD_EXPIRY:09/27}} карты истекает
Держатель карты {{CARDHOLDER:IVAN PETROV}} указан на лицевой стороне
# --- Налоговые и страховые номера ---
ИНН {{INN:770301234550}} налогоплательщика
СНИЛС {{SNILS:112-233-445-95}} застрахованного лица
СНИЛС {{SNILS:661-058-551 91}} в системе
# --- Паспорт РФ ---
Паспорт {{PASSPORT:4509 123456}} выдан ОВД
Серия {{PASSPORT:45 09 № 123456}} паспорта
Паспорт {{PASSPORT:4509123456}} гражданина РФ
выдан {{PASSPORT_ISSUER:ОУФМС России по г. Москве}} 12.05.2015
выдан {{PASSPORT_ISSUER:Отделом УФМС по Республике Татарстан}}, действителен
Дата выдачи {{PASSPORT_DATE:12.05.2015}} паспорта
Код подразделения {{DEPT_CODE:770-123}} указан в паспорте
# --- Другие документы ---
Водительское удостоверение {{DRIVER_LICENSE:77 12 345678}} действительно
Загранпаспорт {{FOREIGN_PASSPORT:71 1234567}} выдан консульством
Военный билет {{MILITARY_ID:АБ 1234567}} на учёте
Свидетельство о рождении {{BIRTH_CERTIFICATE:II-МЮ 123456}} выдано ЗАГС
Полис ОМС {{MEDICAL_POLICY:1234567890123456}} действует
# --- Дата и место рождения ---
Дата рождения {{BIRTH_DATE:12.05.1985}} клиента
Родился {{BIRTH_DATE:15.03.1990}} в городе
Клиент {{FIO:Смирнов Артём}}, место рождения {{BIRTH_PLACE:город Тверь}}
Клиент {{FIO:Волков Игорь}}, родился в {{BIRTH_PLACE:Новосибирске}}
Гражданство {{CITIZENSHIP:РФ}}
Гражданин {{CITIZENSHIP:России}}
Гражданство {{CITIZENSHIP:Республики Беларусь}}
Клиент {{FIO:Петров Пётр}}, дата {{DATE:01.02.2025}} обращения
# --- Адрес ---
Клиент {{FIO:Смирнов Артём}}, страна проживания {{ADDRESS_COUNTRY:Казахстан}}
Индекс {{ADDRESS_POSTCODE:125009}} почтовый
Город {{ADDRESS_CITY:Москва}} регистрации
Улица {{ADDRESS_STREET:Тверская}} дом
Дом {{ADDRESS_HOUSE:7}} корпус
Квартира {{ADDRESS_FLAT:15}} в доме
# --- Банковские реквизиты ---
Клиент {{FIO:Волков Игорь}}, расчётный счёт {{ACCOUNT_NUMBER:40702810500000001234}}
Клиент {{FIO:Петров Пётр}}, р/с {{ACCOUNT_NUMBER:40702810000000001234}} в банке
Клиент {{FIO:Смирнов Артём}}, БИК {{BIK:044525593}} банка-получателя
Директор {{FIO:Волков И.П.}}, ОГРН {{OGRN:1027700132195}} организации
ИП {{FIO:Волков Игорь Петрович}}, ОГРНИП {{OGRNIP:304500116000157}}
Заявитель {{FIO:Волкова Д.И.}}, КПП {{KPP:770101001}} в реквизитах счёта
# --- Доход и биометрия ---
Клиент {{FIO:Смирнов Артём}}, доход {{INCOME:95 000}} руб. подтверждён справкой
Клиент {{FIO:Петров Пётр}}, заработная плата {{INCOME:150000}} перечисляется на карту
Клиент {{FIO:Волков Игорь}} прошёл верификацию через {{BIOMETRIC:биометрические данные}}
Клиент {{FIO:Петров Пётр}}, оформлен {{BIOMETRIC:слепок голоса}} для системы {{BIOMETRIC:ЕБС}}
+132
View File
@@ -0,0 +1,132 @@
# Размеченный набор для замера качества детекции.
#
# Формат: персональные данные обёрнуты в {{ТИП:значение}}. Строка без разметки —
# текст, в котором ПД нет вовсе, и любое срабатывание на ней считается ложным.
# Строки, начинающиеся с #, и пустые строки пропускаются.
#
# Набор смещён в сторону ФИО: это самый сложный для распознавания тип и главный
# источник ложных срабатываний.
# --- ФИО: полное имя с отчеством, разные падежи ---
Клиент {{FIO:Иванов Иван Иванович}} обратился в отделение
Заявление подал {{FIO:Петров Сергей Николаевич}}
{{FIO:Сидорова Анна Петровна}} подтвердила согласие на обработку
Договор подписан с {{FIO:Кузнецовым Дмитрием Алексеевичем}}
Передайте документы {{FIO:Смирнову Олегу Ивановичу}}
Встреча с {{FIO:Ивановым Иваном Ивановичем}} перенесена на следующую неделю
Ответственный — {{FIO:Николаев Юрий Витальевич}}
Доверенность выдана на {{FIO:Белова Глеба Артёмовича}}
{{FIO:Троицкий Лев Маркович}} назначен ответственным
{{FIO:Иванидзе Нина Георгиевна}} подала обращение
Поручение принято от {{FIO:Ясенева Марка Тимуровича}}
Согласовано с {{FIO:Лаврентьевой Ольгой Никитичной}}
# --- ФИО: имя и отчество без фамилии ---
{{FIO:Иван Иванович}} ждёт ответа
Пригласите {{FIO:Ивана Сергеевича}} на встречу
Спросите у {{FIO:Марии Никитичны}} про заявку
Свяжитесь с {{FIO:Анной Ивановной}} по вопросу перевода
Документы передал {{FIO:Пётр Глебович}}
# --- ФИО: фамилия с инициалами ---
Заявление от {{FIO:Иванов И.И.}} принято к рассмотрению
Подписал {{FIO:И.И. Иванов}}
Согласовано: {{FIO:Петрова А.С.}}
Исполнитель {{FIO:Кузнецов Д.А.}}, срок до конца недели
Отчёт подготовил {{FIO:С.Н. Мещеряков}}
# --- ФИО: строчными и прописными буквами ---
ФИО: {{FIO:иванов иван иванович}}
фио: {{FIO:петрова анна ивановна}}
Ф.И.О. {{FIO:смирнов олег петрович}}
Карта оформлена на имя {{FIO:петров сергей}}
{{FIO:ИВАНОВ ИВАН ИВАНОВИЧ}} обратился повторно
Заявитель: {{FIO:КУЗНЕЦОВА АННА СЕРГЕЕВНА}}
# --- ФИО: фамилия рядом с личным именем ---
{{FIO:Сергей Петров}} подтвердил заявку
{{FIO:Петров Сергей}} подтвердил заявку
Обращение {{FIO:Ольги Ковалёвой}} рассмотрено
Перевод получил {{FIO:Артём Тихомиров}}
Заявку оформила {{FIO:Марина Шевченко}}
Поручителем выступает {{FIO:Тимур Мкртчян}}
В списке значится {{FIO:Екатерина Бурмистрова}}
Счёт открыт на {{FIO:Никиту Кольцова}}
# --- ФИО: после ролевого слова ---
Клиент {{FIO:Петров Сергей}}, заявка одобрена
Плательщик {{FIO:Ковалёва}} подтвердила перевод
Сотрудник {{FIO:Николаев Юрий}} оформил заявку
Заказчик {{FIO:Шевченко Марина}} согласовал смету
Пациент {{FIO:Ким Елена}} записан на приём
Абонент {{FIO:Белов Глеб}} не отвечает
Владелец счёта {{FIO:Романов Артём Игоревич}}
Получатель {{FIO:Ясенев Марк}} подтвердил адрес
Действует в лице {{FIO:Иванова Ивана Ивановича}}
# --- ФИО вместе с другими персональными данными ---
Клиент {{FIO:Иванов Иван Иванович}}, паспорт {{PASSPORT:4509 123456}}, тел {{PHONE:+7 916 123-45-67}}
{{FIO:Петров С.А.}}, ИНН {{INN:770301234550}}, карта {{CARD:4111 1111 1111 1111}}
Почта {{EMAIL:ivan.petrov@mail.ru}} принадлежит клиенту {{FIO:Петрову Ивану}}
{{FIO:Сидорова Анна Петровна}}, дата рождения {{BIRTH_DATE:12.05.1985}}, гражданство {{CITIZENSHIP:РФ}}
Клиент {{FIO:Кузнецов Дмитрий Алексеевич}}, СНИЛС {{SNILS:112-233-445 95}}
Оформлено на {{FIO:Смирнова Олега}}, водительское удостоверение {{DRIVER_LICENSE:9902 123456}}
{{FIO:Белова Анна Ивановна}}, место рождения {{BIRTH_PLACE:город Тверь}}
Заявитель {{FIO:Троицкий Лев}}, загранпаспорт {{FOREIGN_PASSPORT:75 1234567}}
Клиент {{FIO:Александр Пушкин}}, паспорт {{PASSPORT:4509 123456}}
Держатель {{CARDHOLDER:IVAN PETROV}}, карта {{CARD:4111 1111 1111 1111}}, CVV {{CVV:123}}
# --- Несколько имён в одном тексте ---
Встречу проводят {{FIO:Иванов Иван Иванович}} и {{FIO:Петрова Анна Сергеевна}}
Согласовали {{FIO:Кузнецов Д.А.}} и {{FIO:Смирнов О.И.}}, возражений нет
# --- Прочие типы ПД для замера точности в реальном окружении ---
Паспорт {{PASSPORT:4509 123456}} выдан {{PASSPORT_ISSUER:ОУФМС России по г. Москве}}, код подразделения {{DEPT_CODE:770-001}}
Серия {{PASSPORT:4509}} номер {{PASSPORT:123456}}, дата выдачи {{PASSPORT_DATE:12.05.2015}}
Адрес: {{ADDRESS_POSTCODE:125009}}, г. {{ADDRESS_CITY:Москва}}, ул. {{ADDRESS_STREET:Тверская}}, д. {{ADDRESS_HOUSE:7}}, кв. {{ADDRESS_FLAT:15}}
Свяжитесь: {{EMAIL:ivan.petrov@mail.ru}} или {{PHONE:+7 (916) 123-45-67}}
Дата рождения {{BIRTH_DATE:12 мая 1985 года}}
Дата рождения {{BIRTH_DATE:двенадцатого мая тысяча девятьсот восемьдесят пятого года}}
Военный билет {{MILITARY_ID:АБ 1234567}} предъявлен
Полис ОМС {{MEDICAL_POLICY:1234567890123456}} оформлен
Свидетельство о рождении {{BIRTH_CERTIFICATE:II-МЮ № 123456}}
ИНН {{INN:770301234550}} проверен в реестре
Карта {{CARD:5555 5555 5555 4444}} заблокирована
Гражданство {{CITIZENSHIP:Республики Беларусь}}, место рождения {{BIRTH_PLACE:город Минск}}
# --- Тексты без персональных данных: любое срабатывание здесь ложное ---
Напиши стихотворение в духе Александра Пушкина про осень
Сравни Толстого и Достоевского как прозаиков
Когда Гагарин полетел в космос
Расскажи о теории Менделеева про периодический закон
Разбери стихотворение Лермонтова Парус
Кто написал оперу Евгений Онегин
Московский Кремль открыт для посещения
Экскурсия в Нижний Новгород перенесена на май
Смоленская площадь закрыта на ремонт
Улица Тверская и Кутузовский проспект перекрыты
Театр имени Вахтангова открыл сезон
Премия имени Ломоносова вручена в декабре
Площадь Гагарина находится на юго-западе
Отделение банка на улице Тверская, дом 7 работает до 20:00
Дополнительный офис, г. Москва, ул. Арбат, д. 1
Банкомат по адресу Ленинский проспект, дом 30 не работает
Банк Открытие подтвердил лимит по овердрафту
Сбербанк России опубликовал квартальный отчёт
Газпром и Роснефть подписали соглашение
Встреча перенесена на 12.05.2025, подтвердите участие
Сервер 192.168.1 отвечает, сборка 1.2.3 развёрнута
Заказ 1234 5678 9012 3456 отгружен со склада
Пин-код 1234 введён неверно, попробуйте снова
Компания выросла на 20 процентов в 2024 году
В Понедельник Отдел Согласует Договор
Расскажи о погоде в Москве завтра
Объясни разницу между дебетовой и кредитной картой
Подготовь план обучения для команды разработки
Какие документы нужны для открытия счёта юридическому лицу
Сколько стоит обслуживание карты в год
Переведи текст на английский язык
Сгенерируй описание вакансии для аналитика данных
Что такое эквайринг и как он работает
Составь список задач на следующий спринт
Проверь орфографию в этом абзаце
+53
View File
@@ -0,0 +1,53 @@
{
"default": {
"enabled": true,
"demask": true,
"maskMode": "MASK",
"types": [
"*"
]
},
"crm": {
"enabled": true,
"demask": false,
"maskMode": "TOKEN",
"types": [
"FIO",
"PHONE",
"EMAIL"
]
},
"analytics": {
"enabled": true,
"demask": false,
"maskMode": "SYNTHETIC",
"types": [
"*"
]
},
"disabled": {
"enabled": false,
"demask": false,
"maskMode": "MASK",
"types": [
"*"
]
},
"other": {
"enabled": true,
"demask": true,
"maskMode": "MASK",
"types": [
"*"
]
},
"guarded": {
"enabled": true,
"demask": true,
"maskMode": "MASK",
"types": [
"*"
],
"key": "s3cret-key-2026"
}
}
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Загрузка модели распознавания для второй ступени.
#
# Модель — BERT (onnx-community/bert-base-NER-Russian-ONNX, лицензия MIT): размечает
# имена и составляющие адреса вплоть до региона и района. Около 170 МБ, в репозиторий
# не кладётся.
#
# ./tools/fetch-ner-model.sh
set -euo pipefail
cd "$(dirname "$0")/.."
BASE=https://huggingface.co/onnx-community/bert-base-NER-Russian-ONNX/resolve/main
DIR=models/rubert-ner
mkdir -p "$DIR"
echo "Загрузка модели в $DIR (около 170 МБ)"
curl -sSL --progress-bar -o "$DIR/model_int8.onnx" "$BASE/onnx/model_int8.onnx"
for f in vocab.txt config.json tokenizer.json tokenizer_config.json; do
curl -sSL -o "$DIR/$f" "$BASE/$f"
done
echo
ls -lh "$DIR"
echo
echo "Готово. Включить: pdguard.ner.engine=rubert, pdguard.ner.model=$DIR"