From 7fb8c6a6c567f6eadf24666a31f3caa8edf85a4d Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:29:22 +0700 Subject: [PATCH] =?UTF-8?q?feat(ci):=20=D0=BC=D0=B0=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D1=86=D0=B0=20Windows=20+=20Linux;=20=D1=80=D0=B0=D1=81=D0=BF?= =?UTF-8?q?=D0=B0=D0=BA=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BE=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BE=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=87=D0=B5=D0=BD=D0=B0=20=D0=BA=D0=B0=D1=82=D0=B0?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 после зелёного main. 1. CI-матрица. Обе джобы стояли на windows-latest, и это дорого обошлось: инвариант A37 не держался на Windows, а четыре теста молча предполагали Linux. Прогон на одной системе не показывал ни того, ни другого. Проект работает на Linux и активно получает Linux-правки — теперь обе системы проверяются одинаковым набором. 2. Zip-slip из аудита НЕ ВОСПРОИЗВОДИТСЯ — измерено, а не принято на веру. Архив с "../", с абсолютным путём и с записью-ссылкой распакован через zipfile.extractall: ничего за пределы каталога не вышло, абсолютный путь стал относительным, "../" схлопнулись, а запись-ссылка легла обычным файлом. CPython санирует пути сам. Но это свойство реализации, а не обещание формата, и распаковка идёт в корень установки. Граница сделана собственным инвариантом: каждая запись проверяется до записи на диск, отклоняются абсолютные пути, выход через "..", ссылки и записи не-файлового типа. Инвариант закреплён тестом, а не оставлен на усмотрение стандартной библиотеки. Тесты: 776 -> 777 passed, 2 skipped, 4 deselected. ruff check . чисто. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 23 ++++++-- .../updater/update_manager.py | 49 +++++++++++++++- tests/test_a59_visible_update.py | 56 +++++++++++++++++++ 3 files changed, 121 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8ca643..cdf3fbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,11 +6,22 @@ on: pull_request: branches: [ main ] +# Матрица из двух систем. +# +# Обе джобы стояли на windows-latest, и это дорого обошлось: инвариант A37 не +# держался на Windows, а тесты установки и остановки процессов молча +# предполагали Linux. Прогон на одной системе не показывал ни того, ни другого. +# Проект работает на Linux и активно получает Linux-правки, поэтому обе системы +# проверяются одинаковым набором. jobs: test: - name: Clean Windows Runner Test - runs-on: windows-latest + name: Clean Runner Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] steps: - name: Checkout repository @@ -40,9 +51,13 @@ jobs: python scripts/release_gate.py headless: - name: Headless Run (no GUI dependencies) - runs-on: windows-latest + name: Headless Run (${{ matrix.os }}, no GUI dependencies) + runs-on: ${{ matrix.os }} timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/src/antigravity_provider/updater/update_manager.py b/src/antigravity_provider/updater/update_manager.py index fd962b4..e3933ba 100644 --- a/src/antigravity_provider/updater/update_manager.py +++ b/src/antigravity_provider/updater/update_manager.py @@ -19,6 +19,7 @@ import logging import os import re import shutil +import zipfile import subprocess import sys import threading @@ -351,6 +352,43 @@ def acknowledge_last_applied_update() -> None: logger.debug("Failed acknowledging last_applied_update: %s", exc) +def _extract_within(zf: "zipfile.ZipFile", dest: Path) -> None: + """Распаковать архив, не выпуская ни одной записи за пределы dest. + + Отклоняются: абсолютные пути, выход через "..", символические ссылки и + любые записи, не являющиеся обычным файлом или каталогом. + """ + import stat as _stat + + root = dest.resolve() + for info in zf.infolist(): + # Биты типа файла проставлены не всегда: архиватор мог записать только + # права доступа. Судим лишь тогда, когда тип действительно указан, — + # иначе обычный файл с правами 0o600 выглядел бы записью чужого типа. + file_type = _stat.S_IFMT(info.external_attr >> 16) + if file_type and file_type not in (_stat.S_IFREG, _stat.S_IFDIR): + raise ValueError( + f"Пакет обновления содержит запись недопустимого типа: {info.filename!r}" + ) + + name = info.filename.replace("\\", "/") + if name.startswith("/") or re.match(r"^[A-Za-z]:", name): + raise ValueError(f"Пакет обновления содержит абсолютный путь: {info.filename!r}") + + target = (root / name).resolve() + if target != root and root not in target.parents: + raise ValueError( + f"Пакет обновления пытается записать за пределы каталога установки: {info.filename!r}" + ) + + if info.is_dir(): + target.mkdir(parents=True, exist_ok=True) + continue + target.parent.mkdir(parents=True, exist_ok=True) + with zf.open(info, "r") as source, open(target, "wb") as out: + shutil.copyfileobj(source, out) + + def stop_running_hub(timeout_sec: float = 10.0) -> bool: """Останавливает только процессы хаба текущего пользователя, исключая текущий PID.""" current_pid = os.getpid() @@ -1212,8 +1250,6 @@ class UpdateManager: def apply_update_sync(self, package_zip: Path, target_dir: Optional[Path] = None) -> Tuple[bool, str]: """Apply update package with automatic backup and rollback on failure.""" - import zipfile - dest = target_dir or paths.get_repo_root() backup = self.backup_dir backup.mkdir(parents=True, exist_ok=True) @@ -1229,8 +1265,15 @@ class UpdateManager: shutil.copytree(src_item, dst_item) # 2. Extract update package into dest + # + # Каждая запись проверяется до записи на диск. Измерено, что + # extractall в CPython уже отбрасывает "..", ведущие разделители и + # буквы дисков, а запись-ссылку кладёт обычным файлом: побега из + # каталога добиться не удалось. Но это свойство реализации, а не + # обещание формата — а распаковка идёт в корень установки. Проверка + # делает границу собственным инвариантом, который виден в тестах. with zipfile.ZipFile(package_zip, "r") as zf: - zf.extractall(dest) + _extract_within(zf, dest) # 3. Verify syntax and integrity of updated python files in src/ import py_compile diff --git a/tests/test_a59_visible_update.py b/tests/test_a59_visible_update.py index bd08e7d..b23c29e 100644 --- a/tests/test_a59_visible_update.py +++ b/tests/test_a59_visible_update.py @@ -556,3 +556,59 @@ def test_p0_2_app_js_handles_refused_cancel(): assert "setInterval(pollUpdateProgress" in tail, ( "после отклонённой отмены опрос хода загрузки должен возобновляться" ) + + +# ── HUB-1: распаковка обновления не выпускает записи за пределы каталога ── + + +@pytest.mark.unit +def test_update_package_cannot_write_outside_target(tmp_path): + """Ни одна запись архива не должна оказаться вне каталога установки. + + Измерено на CPython: extractall сам отбрасывает "..", ведущие разделители + и буквы дисков, а запись-ссылку кладёт обычным файлом — побега добиться не + удалось, вопреки формулировке аудита. Но это свойство реализации, а не + обещание формата, и распаковка идёт в корень установки. Тест закрепляет + границу как собственный инвариант. + """ + import stat as _stat + import zipfile as _zipfile + from antigravity_provider.updater.update_manager import _extract_within + + dest = tmp_path / "dest" + dest.mkdir() + outside = tmp_path / "outside.txt" + + hostile = [ + ("выход через ..", "../outside.txt"), + ("абсолютный путь", "/etc/passwd"), + ("путь с буквой диска", "C:/Windows/x.txt"), + ] + for index, (label, arcname) in enumerate(hostile): + archive = tmp_path / f"hostile_{index}.zip" + with _zipfile.ZipFile(archive, "w") as zf: + zf.writestr(arcname, "побег") + with _zipfile.ZipFile(archive) as zf: + with pytest.raises(ValueError): + _extract_within(zf, dest) + assert not outside.exists(), f"{label}: запись оказалась вне каталога установки" + + # Символическая ссылка — тоже отказ, а не молчаливая распаковка файлом. + link_zip = tmp_path / "link.zip" + with _zipfile.ZipFile(link_zip, "w") as zf: + info = _zipfile.ZipInfo("link") + info.external_attr = (_stat.S_IFLNK | 0o777) << 16 + zf.writestr(info, "/etc/passwd") + with _zipfile.ZipFile(link_zip) as zf: + with pytest.raises(ValueError): + _extract_within(zf, dest) + + # Обычный пакет распаковывается как прежде. + good = tmp_path / "good.zip" + with _zipfile.ZipFile(good, "w") as zf: + zf.writestr("src/module.py", "x = 1\n") + zf.writestr("assets/logo.txt", "logo") + with _zipfile.ZipFile(good) as zf: + _extract_within(zf, dest) + assert (dest / "src" / "module.py").read_text(encoding="utf-8") == "x = 1\n" + assert (dest / "assets" / "logo.txt").is_file()