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 версии
`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 — не создан

View File

@@ -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 — не создан

View File

@@ -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"}

View File

@@ -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(
"""

View File

@@ -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": '<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>"})
@@ -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()

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-бот на 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 — не создан

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 — не создан