feat(ci): матрица Windows + Linux; распаковка обновления ограничена каталогом

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 <noreply@anthropic.com>
This commit is contained in:
ochenstarik-ui 2026-09-03 01:29:22 +07:00
parent 61e7933334
commit 7fb8c6a6c5
3 changed files with 121 additions and 7 deletions

View file

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

View file

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

View file

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