diff --git a/docs/ops/hardening-2026-09-10.md b/docs/ops/hardening-2026-09-10.md index f8d51f59..e2951860 100644 --- a/docs/ops/hardening-2026-09-10.md +++ b/docs/ops/hardening-2026-09-10.md @@ -70,6 +70,9 @@ UID 10001 имеет настоящую запись пользователя `r до создания кандидата. `npm ci` использует строгую проверку engines и повторяет только сетевые сбои: максимум три попытки под общим таймаутом и контролем диска. Ошибки зависимостей и postinstall не маскируются повторными попытками. +В новой Python-venv закреплён pip 26.2.1 с проверкой SHA-256 bootstrap-wheel +и ограниченной докачкой оборванных загрузок. Несовпадение хешей по-прежнему +останавливает установку; активная venv и системный pip не обновляются. Остановка Gunicorn использует SIGTERM и достаточный таймаут мягкого завершения. Код 143 Next.js при штатной остановке не считается аварией. Telegram-транспорт diff --git a/ops/deploy/README.md b/ops/deploy/README.md index 787cff7c..fc53344c 100644 --- a/ops/deploy/README.md +++ b/ops/deploy/README.md @@ -68,6 +68,11 @@ attempts under one 30-minute timeout and the same disk watchdog. Dependency, engine and postinstall failures are not retried. The per-candidate npm cache is removed after installation and with any failed candidate. +Each Python candidate bootstraps the pinned, SHA-256-verified pip wheel in +`pip-bootstrap.txt`. Package downloads use bounded retries/resume for truncated +network transfers. TLS and package hash checks remain enabled; a real integrity +mismatch still fails the deployment. The system pip and active venv are not changed. + The deployment requires at least 4 GiB and 50,000 free inodes before creating a release. Build and database-dump process groups have a 30-minute timeout and are monitored every two seconds; they are stopped if free space falls below 512 MiB diff --git a/ops/deploy/eusrr-deploy b/ops/deploy/eusrr-deploy index 0b5ec4ba..e19e6401 100755 --- a/ops/deploy/eusrr-deploy +++ b/ops/deploy/eusrr-deploy @@ -668,7 +668,12 @@ log "Creating an isolated Python environment." run_bounded_build /usr/bin/python3 -m venv "${NEW_RELEASE}/backend/.venv" run_bounded_build env PIP_DISABLE_PIP_VERSION_CHECK=1 \ "${NEW_RELEASE}/backend/.venv/bin/python" -m pip install \ - --no-cache-dir --requirement "${NEW_RELEASE}/backend/requirements.txt" + --no-cache-dir --timeout=60 --retries=5 --only-binary=:all: --require-hashes \ + --requirement "${NEW_RELEASE}/ops/deploy/pip-bootstrap.txt" +run_bounded_build env PIP_DISABLE_PIP_VERSION_CHECK=1 \ + "${NEW_RELEASE}/backend/.venv/bin/python" -m pip install \ + --no-cache-dir --timeout=60 --retries=5 --resume-retries=5 \ + --requirement "${NEW_RELEASE}/backend/requirements.txt" run_bounded_build "${NEW_RELEASE}/backend/.venv/bin/python" -m pip check run_bounded_build "${NEW_RELEASE}/backend/.venv/bin/python" -c 'import shutil; assert shutil.which("magick") or shutil.which("convert"), "ImageMagick is required for safe oversized-image processing"' run_bounded_build "${NEW_RELEASE}/backend/.venv/bin/python" \ diff --git a/ops/deploy/pip-bootstrap.txt b/ops/deploy/pip-bootstrap.txt new file mode 100644 index 00000000..b5a430ef --- /dev/null +++ b/ops/deploy/pip-bootstrap.txt @@ -0,0 +1,2 @@ +# PyPI wheel for pip 26.2.1; bootstrap itself remains hash-verified. +pip==26.2.1 --hash=sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e diff --git a/ops/deploy/tests/test_deploy_lifecycle.py b/ops/deploy/tests/test_deploy_lifecycle.py index 65953315..a8aef69a 100644 --- a/ops/deploy/tests/test_deploy_lifecycle.py +++ b/ops/deploy/tests/test_deploy_lifecycle.py @@ -236,6 +236,18 @@ class DeploymentLifecycleTests(unittest.TestCase): self.assertFalse((self.releases/TARGET).exists()) self.assertFalse((self.root/'npm_attempts').exists()) + def test_pip_bootstrap_is_pinned_and_download_resume_keeps_hash_checks(self): + result = self.run_deploy() + self.assertEqual(result.returncode, 0, self.output) + commands = (self.root/'commands').read_text() + self.assertIn('pip-bootstrap.txt', commands) + self.assertIn('--require-hashes', commands) + self.assertIn('--resume-retries=5', commands) + self.assertNotIn('--trusted-host', commands) + self.assertNotIn('--no-require-hashes', commands) + bootstrap = (SOURCE.parent/'pip-bootstrap.txt').read_text() + self.assertIn('pip==26.2.1 --hash=sha256:', bootstrap) + def test_source_override_is_rejected_without_touching_live_release(self): for stage in ('origin', 'url_rewrite'): with self.subTest(stage=stage):