From 752bd38c9b62aceeddf2b815c9e086646df5af85 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 12 Aug 2026 09:49:40 +0300 Subject: [PATCH] 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. --- README.md | 8 ++--- backend/README.md | 6 ++-- backend/ingestion/minjust_cbd.py | 2 +- backend/normalization/minjust_cbd.py | 36 ++++++++++++++++--- backend/test_minjust_normalization.py | 33 ++++++++++++++++- docs/README.md | 2 +- docs/decisions/001-telegram-workspace-mvp.md | 2 +- docs/operations/project-status.md | 11 +++--- docs/operations/telegram-workspace-plan.md | 2 +- docs/product/frontend-search-sps-plan.md | 2 +- ...njust-document-normalization-agent-task.md | 2 +- docs/product/project-overview.md | 2 +- docs/team/ai-skills-for-beginners.md | 2 +- tools/telegram-bot/README.md | 2 +- 14 files changed, 87 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 99fb364..b12b3fa 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,14 @@ Telegram-бот — только часть рабочего окружения ## Текущее состояние Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии -`0.2.1`: возобновляемая выгрузка и нормализация документов ЦБД Минюста КР. +`0.2.2`: возобновляемая выгрузка и нормализация документов ЦБД Минюста КР. | Компонент | Версия | Состояние | |---|---:|---| | Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния | -| Backend | `0.2.1` | реализованы выгрузка и нормализация документов ЦБД Минюста КР | +| Backend | `0.2.2` | реализованы выгрузка и нормализация документов ЦБД Минюста КР | | Frontend | — | ещё не создан | -| Сбор и обработка правовых данных | `0.2.1` | реализованы архиватор и нормализатор ЦБД Минюста КР | +| Сбор и обработка правовых данных | `0.2.2` | реализованы архиватор и нормализатор ЦБД Минюста КР | | 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 — не создан diff --git a/backend/README.md b/backend/README.md index 8b841d1..1d36a0f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # Backend Акылдаш -Версия: `0.2.1` +Версия: `0.2.2` Первая backend-область проекта — загрузка правовых документов из официального Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в @@ -68,6 +68,8 @@ python3 backend/normalization/minjust_cbd.py в `data/minjust-normalized`. Пути можно изменить параметрами `--input` и `--output`; `--refresh` принудительно обрабатывает неизменившиеся документы, `--log-level` задаёт уровень журнала. +Каталоги `--input` и `--output` не должны совпадать, содержать друг друга или +пересекаться через разрешённые абсолютные пути. ```text 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 — не создан diff --git a/backend/ingestion/minjust_cbd.py b/backend/ingestion/minjust_cbd.py index a46e364..e40db76 100644 --- a/backend/ingestion/minjust_cbd.py +++ b/backend/ingestion/minjust_cbd.py @@ -21,7 +21,7 @@ from datetime import datetime, timezone from pathlib import Path 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/" LANGUAGES = {"Rus": "ru", "Kyr": "ky"} IMAGE_LANGUAGES = {"Russian": "ru", "Kyrgyz": "ky"} diff --git a/backend/normalization/minjust_cbd.py b/backend/normalization/minjust_cbd.py index b1b40a6..9631b21 100644 --- a/backend/normalization/minjust_cbd.py +++ b/backend/normalization/minjust_cbd.py @@ -23,7 +23,7 @@ from pathlib import Path from typing import Callable from urllib.parse import urlsplit -APP_VERSION = "0.2.1" +APP_VERSION = "0.2.2" SCHEMA_VERSION = "1" NORMALIZER_VERSION = "1.0.0" LANGUAGES = ("ru", "ky") @@ -234,7 +234,10 @@ class SafeHtmlParser(HTMLParser): return safe 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(("/", "\\")): return False candidate = (self.edition_directory / parsed.path.replace("\\", "/")).resolve() @@ -249,7 +252,10 @@ def safe_link(value: str) -> bool: value = value.strip() if not value or value.startswith(("//", "\\\\")): return False - parsed = urlsplit(value) + try: + parsed = urlsplit(value) + except ValueError: + return False return parsed.scheme.lower() in {"", "http", "https", "mailto"} and not ( not parsed.scheme and parsed.netloc ) @@ -531,6 +537,23 @@ def publish_directory(staged: Path, target: Path) -> None: 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( input_root: Path = Path("data/minjust-cbd"), output: Path = Path("data/minjust-normalized"), @@ -538,6 +561,7 @@ def normalize_archive( refresh: bool = False, progress: Callable[[NormalizeResult, int], None] | None = None, ) -> NormalizeResult: + validate_roots(input_root, output) document_root = input_root / "documents" if not document_root.is_dir(): raise FileNotFoundError(f"Document directory not found: {document_root}") @@ -563,10 +587,12 @@ def normalize_archive( for source_directory in directories: discovered += 1 code = source_directory.name + target = output / "documents" / code checksum = None try: + recover_directory(target) 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" ): skipped += 1 @@ -574,7 +600,7 @@ def normalize_archive( with tempfile.TemporaryDirectory(dir=staging_root) as temporary: staged = Path(temporary) / code normalize_document(source_directory, input_root, staged, files, checksum) - publish_directory(staged, output / "documents" / code) + publish_directory(staged, target) with connection: connection.execute( """ diff --git a/backend/test_minjust_normalization.py b/backend/test_minjust_normalization.py index 98d31c7..bfc54a6 100644 --- a/backend/test_minjust_normalization.py +++ b/backend/test_minjust_normalization.py @@ -1,4 +1,5 @@ import json +import os import sqlite3 import tempfile import unittest @@ -55,7 +56,7 @@ class MinjustNormalizationTest(unittest.TestCase): document, 10, { - "ru": '

Статья 1 Закон

ссылка', + "ru": '

Статья 1 Закон

ссылкасломанная ссылка', }, ) self.edition(document, 20, {"ky": "

1. Кыргызча жобо

"}) @@ -73,6 +74,7 @@ class MinjustNormalizationTest(unittest.TestCase): self.assertNotIn("script", safe_html) self.assertNotIn("style=", safe_html) self.assertNotIn("javascript:", safe_html) + self.assertNotIn("http://[", safe_html) self.assertIn("Статья 1 Закон", safe_html) parsed = json.loads(fragments) self.assertEqual(parsed[0]["type"], "article") @@ -116,6 +118,35 @@ class MinjustNormalizationTest(unittest.TestCase): ("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__": unittest.main() diff --git a/docs/README.md b/docs/README.md index a7b63cb..13b2d5c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,4 +33,4 @@ --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан diff --git a/docs/decisions/001-telegram-workspace-mvp.md b/docs/decisions/001-telegram-workspace-mvp.md index 0b7bfa2..616363a 100644 --- a/docs/decisions/001-telegram-workspace-mvp.md +++ b/docs/decisions/001-telegram-workspace-mvp.md @@ -78,4 +78,4 @@ Telegram позволяет запретить пользователям отп --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан diff --git a/docs/operations/project-status.md b/docs/operations/project-status.md index de5b13a..a2cf417 100644 --- a/docs/operations/project-status.md +++ b/docs/operations/project-status.md @@ -5,7 +5,7 @@ - Telegram-бот: `0.2.2` - Telegram-бот на Synology: `0.2.1` -- Backend: `0.2.1` +- Backend: `0.2.2` - Frontend: не создан ## Краткий итог @@ -68,10 +68,10 @@ - Локальный Git-репозиторий восстановлен и привязан к Gitea. - Репозиторий организован как основа всего проекта, а не отдельного бота. - Бот развёрнут в Container Manager на Synology; автозапуск после перезапуска менеджера проверен. -- Реализован backend-загрузчик ЦБД Минюста КР версии `0.2.1` без внешних зависимостей. +- Реализован backend-загрузчик ЦБД Минюста КР версии `0.2.2` без внешних зависимостей. - Загрузчик сохраняет метаданные, редакции RU/KY и изображения, а прогресс — в SQLite. - Пилотная выгрузка двух документов и возобновление без повторного скачивания проверены на живом API. -- Реализован backend-нормализатор версии `0.2.1` без внешних зависимостей. +- Реализован backend-нормализатор версии `0.2.2` без внешних зависимостей. - Нормализатор создаёт канонические метаданные, безопасный HTML, чистый текст и адресуемые фрагменты RU/KY. - SQLite-манифест обеспечивает возобновление, повтор ошибок и пропуск неизменившихся документов. @@ -134,6 +134,9 @@ ### 2026-08-12 +- Нормализатор запрещает пересекающиеся каталоги источника и результата, + восстанавливает прерванную публикацию и отбрасывает некорректные URL. +- Версия backend обновлена до `0.2.2`. - Загрузчик пересоздаёт временный список документов на текущей странице не только после HTTP 404, но и после исчерпания повторов запроса списка. - Версия 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 — не создан diff --git a/docs/operations/telegram-workspace-plan.md b/docs/operations/telegram-workspace-plan.md index 084ec2f..7dabeaa 100644 --- a/docs/operations/telegram-workspace-plan.md +++ b/docs/operations/telegram-workspace-plan.md @@ -398,4 +398,4 @@ Git сохраняет актуальную версию --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан diff --git a/docs/product/frontend-search-sps-plan.md b/docs/product/frontend-search-sps-plan.md index fa69f7d..552e0a9 100644 --- a/docs/product/frontend-search-sps-plan.md +++ b/docs/product/frontend-search-sps-plan.md @@ -258,4 +258,4 @@ runtime-зависимостями frontend. Регистрация в стор --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан diff --git a/docs/product/minjust-document-normalization-agent-task.md b/docs/product/minjust-document-normalization-agent-task.md index 67febfc..ff85ecc 100644 --- a/docs/product/minjust-document-normalization-agent-task.md +++ b/docs/product/minjust-document-normalization-agent-task.md @@ -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 — не создан diff --git a/docs/product/project-overview.md b/docs/product/project-overview.md index 254f1b2..8c8b926 100644 --- a/docs/product/project-overview.md +++ b/docs/product/project-overview.md @@ -44,4 +44,4 @@ --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан diff --git a/docs/team/ai-skills-for-beginners.md b/docs/team/ai-skills-for-beginners.md index a022048..0ad0508 100644 --- a/docs/team/ai-skills-for-beginners.md +++ b/docs/team/ai-skills-for-beginners.md @@ -180,4 +180,4 @@ --- -Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан +Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан diff --git a/tools/telegram-bot/README.md b/tools/telegram-bot/README.md index 0b2ae58..066a23a 100644 --- a/tools/telegram-bot/README.md +++ b/tools/telegram-bot/README.md @@ -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 — не создан