fix: harden document normalization

Reject overlapping source and destination roots before any write so normalization cannot replace the raw Ministry archive.

Recover an interrupted directory publication before resume checks and require the normalized target to exist before skipping a manifest success.

Treat malformed link and image URLs as unsafe attributes, add regressions for all review findings, and bump the backend version to 0.2.2.
This commit is contained in:
2026-08-12 09:49:40 +03:00
parent 07381ad70f
commit 752bd38c9b
14 changed files with 87 additions and 25 deletions

View File

@@ -10,14 +10,14 @@ Telegram-бот — только часть рабочего окружения
## Текущее состояние ## Текущее состояние
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
`0.2.1`: возобновляемая выгрузка и нормализация документов ЦБД Минюста КР. `0.2.2`: возобновляемая выгрузка и нормализация документов ЦБД Минюста КР.
| Компонент | Версия | Состояние | | Компонент | Версия | Состояние |
|---|---:|---| |---|---:|---|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния | | Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
| Backend | `0.2.1` | реализованы выгрузка и нормализация документов ЦБД Минюста КР | | Backend | `0.2.2` | реализованы выгрузка и нормализация документов ЦБД Минюста КР |
| Frontend | — | ещё не создан | | Frontend | — | ещё не создан |
| Сбор и обработка правовых данных | `0.2.1` | реализованы архиватор и нормализатор ЦБД Минюста КР | | Сбор и обработка правовых данных | `0.2.2` | реализованы архиватор и нормализатор ЦБД Минюста КР |
| RAG и база знаний | — | ещё не созданы | | RAG и база знаний | — | ещё не созданы |
## Структура репозитория ## Структура репозитория
@@ -58,4 +58,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан

View File

@@ -1,6 +1,6 @@
# Backend Акылдаш # Backend Акылдаш
Версия: `0.2.1` Версия: `0.2.2`
Первая backend-область проекта — загрузка правовых документов из официального Первая backend-область проекта — загрузка правовых документов из официального
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
@@ -68,6 +68,8 @@ python3 backend/normalization/minjust_cbd.py
в `data/minjust-normalized`. Пути можно изменить параметрами `--input` и в `data/minjust-normalized`. Пути можно изменить параметрами `--input` и
`--output`; `--refresh` принудительно обрабатывает неизменившиеся документы, `--output`; `--refresh` принудительно обрабатывает неизменившиеся документы,
`--log-level` задаёт уровень журнала. `--log-level` задаёт уровень журнала.
Каталоги `--input` и `--output` не должны совпадать, содержать друг друга или
пересекаться через разрешённые абсолютные пути.
```text ```text
data/minjust-normalized/ data/minjust-normalized/
@@ -98,4 +100,4 @@ PYTHONPATH=backend python3 -m unittest backend/test_minjust_normalization.py -v
--- ---
Акылдаш · Backend v0.2.1 · Frontend — не создан Акылдаш · Backend v0.2.2 · Frontend — не создан

View File

@@ -21,7 +21,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Callable, Iterable from typing import Callable, Iterable
APP_VERSION = "0.2.1" APP_VERSION = "0.2.2"
API_BASE_URL = "https://cbd.minjust.gov.kg/api/v1/OpenData/" API_BASE_URL = "https://cbd.minjust.gov.kg/api/v1/OpenData/"
LANGUAGES = {"Rus": "ru", "Kyr": "ky"} LANGUAGES = {"Rus": "ru", "Kyr": "ky"}
IMAGE_LANGUAGES = {"Russian": "ru", "Kyrgyz": "ky"} IMAGE_LANGUAGES = {"Russian": "ru", "Kyrgyz": "ky"}

View File

@@ -23,7 +23,7 @@ from pathlib import Path
from typing import Callable from typing import Callable
from urllib.parse import urlsplit from urllib.parse import urlsplit
APP_VERSION = "0.2.1" APP_VERSION = "0.2.2"
SCHEMA_VERSION = "1" SCHEMA_VERSION = "1"
NORMALIZER_VERSION = "1.0.0" NORMALIZER_VERSION = "1.0.0"
LANGUAGES = ("ru", "ky") LANGUAGES = ("ru", "ky")
@@ -234,7 +234,10 @@ class SafeHtmlParser(HTMLParser):
return safe return safe
def safe_image(self, value: str) -> bool: def safe_image(self, value: str) -> bool:
parsed = urlsplit(value) try:
parsed = urlsplit(value)
except ValueError:
return False
if parsed.scheme or parsed.netloc or not parsed.path or parsed.path.startswith(("/", "\\")): if parsed.scheme or parsed.netloc or not parsed.path or parsed.path.startswith(("/", "\\")):
return False return False
candidate = (self.edition_directory / parsed.path.replace("\\", "/")).resolve() candidate = (self.edition_directory / parsed.path.replace("\\", "/")).resolve()
@@ -249,7 +252,10 @@ def safe_link(value: str) -> bool:
value = value.strip() value = value.strip()
if not value or value.startswith(("//", "\\\\")): if not value or value.startswith(("//", "\\\\")):
return False return False
parsed = urlsplit(value) try:
parsed = urlsplit(value)
except ValueError:
return False
return parsed.scheme.lower() in {"", "http", "https", "mailto"} and not ( return parsed.scheme.lower() in {"", "http", "https", "mailto"} and not (
not parsed.scheme and parsed.netloc not parsed.scheme and parsed.netloc
) )
@@ -531,6 +537,23 @@ def publish_directory(staged: Path, target: Path) -> None:
shutil.rmtree(backup) shutil.rmtree(backup)
def recover_directory(target: Path) -> None:
backup = target.parent / f".{target.name}.previous"
if not backup.exists():
return
if target.exists():
shutil.rmtree(backup)
else:
os.replace(backup, target)
def validate_roots(input_root: Path, output: Path) -> None:
source = input_root.resolve()
destination = output.resolve()
if source == destination or source.is_relative_to(destination) or destination.is_relative_to(source):
raise ValueError("--input and --output must not overlap")
def normalize_archive( def normalize_archive(
input_root: Path = Path("data/minjust-cbd"), input_root: Path = Path("data/minjust-cbd"),
output: Path = Path("data/minjust-normalized"), output: Path = Path("data/minjust-normalized"),
@@ -538,6 +561,7 @@ def normalize_archive(
refresh: bool = False, refresh: bool = False,
progress: Callable[[NormalizeResult, int], None] | None = None, progress: Callable[[NormalizeResult, int], None] | None = None,
) -> NormalizeResult: ) -> NormalizeResult:
validate_roots(input_root, output)
document_root = input_root / "documents" document_root = input_root / "documents"
if not document_root.is_dir(): if not document_root.is_dir():
raise FileNotFoundError(f"Document directory not found: {document_root}") raise FileNotFoundError(f"Document directory not found: {document_root}")
@@ -563,10 +587,12 @@ def normalize_archive(
for source_directory in directories: for source_directory in directories:
discovered += 1 discovered += 1
code = source_directory.name code = source_directory.name
target = output / "documents" / code
checksum = None checksum = None
try: try:
recover_directory(target)
files, checksum = source_inventory(source_directory, input_root) files, checksum = source_inventory(source_directory, input_root)
if not refresh and known.get(code) == ( if target.is_dir() and not refresh and known.get(code) == (
checksum, SCHEMA_VERSION, NORMALIZER_VERSION, "success" checksum, SCHEMA_VERSION, NORMALIZER_VERSION, "success"
): ):
skipped += 1 skipped += 1
@@ -574,7 +600,7 @@ def normalize_archive(
with tempfile.TemporaryDirectory(dir=staging_root) as temporary: with tempfile.TemporaryDirectory(dir=staging_root) as temporary:
staged = Path(temporary) / code staged = Path(temporary) / code
normalize_document(source_directory, input_root, staged, files, checksum) normalize_document(source_directory, input_root, staged, files, checksum)
publish_directory(staged, output / "documents" / code) publish_directory(staged, target)
with connection: with connection:
connection.execute( connection.execute(
""" """

View File

@@ -1,4 +1,5 @@
import json import json
import os
import sqlite3 import sqlite3
import tempfile import tempfile
import unittest import unittest
@@ -55,7 +56,7 @@ class MinjustNormalizationTest(unittest.TestCase):
document, document,
10, 10,
{ {
"ru": '<meta charset=unicode><style>x</style><p style="color:red">Статья 1 Закон<script>bad()</script></p><a href="javascript:bad">ссылка</a>', "ru": '<meta charset=unicode><style>x</style><p style="color:red">Статья 1 Закон<script>bad()</script></p><a href="javascript:bad">ссылка</a><a href="http://[">сломанная ссылка</a><img src="http://[">',
}, },
) )
self.edition(document, 20, {"ky": "<p>1. Кыргызча жобо</p>"}) self.edition(document, 20, {"ky": "<p>1. Кыргызча жобо</p>"})
@@ -73,6 +74,7 @@ class MinjustNormalizationTest(unittest.TestCase):
self.assertNotIn("script", safe_html) self.assertNotIn("script", safe_html)
self.assertNotIn("style=", safe_html) self.assertNotIn("style=", safe_html)
self.assertNotIn("javascript:", safe_html) self.assertNotIn("javascript:", safe_html)
self.assertNotIn("http://[", safe_html)
self.assertIn("Статья 1 Закон", safe_html) self.assertIn("Статья 1 Закон", safe_html)
parsed = json.loads(fragments) parsed = json.loads(fragments)
self.assertEqual(parsed[0]["type"], "article") self.assertEqual(parsed[0]["type"], "article")
@@ -116,6 +118,35 @@ class MinjustNormalizationTest(unittest.TestCase):
("success", None, None), ("success", None, None),
) )
def test_rejects_overlapping_input_and_output(self):
with tempfile.TemporaryDirectory() as temporary:
source = Path(temporary) / "source"
metadata = self.document(source) / "metadata.json"
original = metadata.read_bytes()
for output in (source, source / "normalized", source.parent):
with self.subTest(output=output):
with self.assertRaisesRegex(ValueError, "must not overlap"):
normalize_archive(source, output)
self.assertEqual(metadata.read_bytes(), original)
def test_recovers_interrupted_directory_publication_before_skip(self):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
source = base / "source"
output = base / "normalized"
self.document(source)
first = normalize_archive(source, output)
target = output / "documents/1"
backup = output / "documents/.1.previous"
os.replace(target, backup)
second = normalize_archive(source, output)
self.assertEqual((first.normalized, second.skipped), (1, 1))
self.assertTrue((target / "document.json").is_file())
self.assertFalse(backup.exists())
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -33,4 +33,4 @@
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан

View File

@@ -78,4 +78,4 @@ Telegram позволяет запретить пользователям отп
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан

View File

@@ -5,7 +5,7 @@
- Telegram-бот: `0.2.2` - Telegram-бот: `0.2.2`
- Telegram-бот на Synology: `0.2.1` - Telegram-бот на Synology: `0.2.1`
- Backend: `0.2.1` - Backend: `0.2.2`
- Frontend: не создан - Frontend: не создан
## Краткий итог ## Краткий итог
@@ -68,10 +68,10 @@
- Локальный Git-репозиторий восстановлен и привязан к Gitea. - Локальный Git-репозиторий восстановлен и привязан к Gitea.
- Репозиторий организован как основа всего проекта, а не отдельного бота. - Репозиторий организован как основа всего проекта, а не отдельного бота.
- Бот развёрнут в Container Manager на Synology; автозапуск после перезапуска менеджера проверен. - Бот развёрнут в Container Manager на Synology; автозапуск после перезапуска менеджера проверен.
- Реализован backend-загрузчик ЦБД Минюста КР версии `0.2.1` без внешних зависимостей. - Реализован backend-загрузчик ЦБД Минюста КР версии `0.2.2` без внешних зависимостей.
- Загрузчик сохраняет метаданные, редакции RU/KY и изображения, а прогресс — в SQLite. - Загрузчик сохраняет метаданные, редакции RU/KY и изображения, а прогресс — в SQLite.
- Пилотная выгрузка двух документов и возобновление без повторного скачивания проверены на живом API. - Пилотная выгрузка двух документов и возобновление без повторного скачивания проверены на живом API.
- Реализован backend-нормализатор версии `0.2.1` без внешних зависимостей. - Реализован backend-нормализатор версии `0.2.2` без внешних зависимостей.
- Нормализатор создаёт канонические метаданные, безопасный HTML, чистый текст и адресуемые фрагменты RU/KY. - Нормализатор создаёт канонические метаданные, безопасный HTML, чистый текст и адресуемые фрагменты RU/KY.
- SQLite-манифест обеспечивает возобновление, повтор ошибок и пропуск неизменившихся документов. - SQLite-манифест обеспечивает возобновление, повтор ошибок и пропуск неизменившихся документов.
@@ -134,6 +134,9 @@
### 2026-08-12 ### 2026-08-12
- Нормализатор запрещает пересекающиеся каталоги источника и результата,
восстанавливает прерванную публикацию и отбрасывает некорректные URL.
- Версия backend обновлена до `0.2.2`.
- Загрузчик пересоздаёт временный список документов на текущей странице не - Загрузчик пересоздаёт временный список документов на текущей странице не
только после HTTP 404, но и после исчерпания повторов запроса списка. только после HTTP 404, но и после исчерпания повторов запроса списка.
- Версия backend обновлена до `0.2.1`. - Версия backend обновлена до `0.2.1`.
@@ -194,4 +197,4 @@
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан

View File

@@ -398,4 +398,4 @@ Git сохраняет актуальную версию
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан

View File

@@ -258,4 +258,4 @@ runtime-зависимостями frontend. Регистрация в стор
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан

View File

@@ -214,4 +214,4 @@ python3 backend/normalization/minjust_cbd.py
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан

View File

@@ -44,4 +44,4 @@
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан

View File

@@ -180,4 +180,4 @@
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан

View File

@@ -48,4 +48,4 @@ python3 -m unittest -v
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан