gendesign/backend/tests/ops/test_2203_env_offbox.py
bot-backend de940c7534
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
feat(ops): off-box копия рантайм-конфига — зашифрованной, иначе никак
Четвёртый пункт приёмки #2203. В дампах есть колонки под pgp_sym_encrypt, ключ
к ним лежит на самой машине и никуда не уезжает: потеря машины означает «дампы
есть, расшифровать нечем». Ради этой связки шифрование в базе и заводилось.

КУДА И ПОЧЕМУ ИМЕННО ТУДА. В тот же S3, отдельным префиксом env/, и только
зашифрованным. Разобранные варианты:

  - рядом с дампами открытым — нельзя: ключ рядом с шифротекстом обнуляет
    шифрование, одна утечка доступа к бакету отдаёт и то и другое;
  - на соседний хост по SSH — потребовало бы завести доверие между машинами,
    которого нет (проверено: Permission denied (publickey)), то есть РАСШИРИТЬ
    периметр ровно тогда, когда #3075 его сужает;
  - отдельный бакет с отдельными ключами — правильнее всего, но требует новых
    учётных данных.

Выбран единственный исполнимый без расширения доступа: шифротекст в S3,
парольная фраза — вне S3.

FAIL-CLOSED. Без фразы скрипт не выгружает файл открытым, а падает с явным
сообщением и алертом. Молчаливая выгрузка ключа в бакет с дампами хуже
отсутствия копии: создаёт ложное чувство защищённости.

Фраза уходит через дескриптор, а не аргументом — иначе видна в ps любому
пользователю машины. После шифрования файл проверяется обратной расшифровкой:
без этого можно годами возить нечитаемый мусор и узнать в тот момент, когда он
понадобился.

Прогон на хосте 27.08 (подставной исходник, боевой не трогался):
  без фразы → код 1, файлов создано 0
  с фразой  → «Зашифровано и проверено расшифровкой», 110 байт
  своей фразой читается, чужой — нет

ОДИН ШАГ ЗА ВЛАДЕЛЬЦЕМ, и он неустраним: фраза обязана жить там, где переживёт
смерть машины, иначе копия бесполезна — расшифровать будет нечем. Сгенерировать
её здесь и оставить на хосте нельзя по построению. Инструкция — в шапке скрипта.

Пять тестов сторожат ровно те свойства, ради которых всё сделано.
Прогон: 85 ops-тестов зелёные, ruff чист.

Refs #2203
2026-08-27 16:32:27 +03:00

97 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Off-box копия рантайм-конфига не должна уезжать в S3 открытой (#2203).
ЗАЧЕМ КОПИЯ. В дампах есть колонки под `pgp_sym_encrypt`. Ключ к ним лежит в
рантайм-конфиге на самой машине. Дампы уезжают в S3 ежедневно, ключ — никуда:
потеря машины означает «дампы есть, расшифровать нечем».
ГЛАВНОЕ СВОЙСТВО, КОТОРОЕ ЗДЕСЬ СТОРОЖИТСЯ. Копия кладётся в тот же бакет, что
и дампы — другого места без расширения периметра доступа нет (SSH между хостами
отсутствует, проверено). Поэтому она обязана быть зашифрована: ключ рядом с
шифротекстом обнуляет шифрование целиком.
Отсюда fail-closed: без парольной фразы скрипт ПАДАЕТ, а не выгружает файл
открытым. Молчаливая выгрузка ключа в бакет с дампами хуже отсутствия копии —
она создаёт ложное чувство защищённости.
Проверено прогоном на хосте 27.08 (подставной исходник, боевой не трогался):
без фразы → код 1, файлов создано 0
с фразой → «Зашифровано и проверено расшифровкой», 110 байт
своей фразой читается, чужой — нет
"""
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
SCRIPT = REPO_ROOT / "ops" / "backup-env-offbox.sh"
CRON = REPO_ROOT / "ops" / "crontab-poincare.cron"
def _text() -> str:
return SCRIPT.read_text(encoding="utf-8")
def _code() -> str:
"""Только исполняемые строки, без комментариев.
В шапке скрипта лежит инструкция по восстановлению, и в ней тоже есть
`gpg --decrypt`. Проверки порядка обязаны смотреть на код — иначе они
сравнивают позиции в документации, а не в программе.
"""
lines = [ln for ln in _text().splitlines() if not ln.lstrip().startswith("#")]
return chr(10).join(lines)
def test_script_exists_and_is_wired_into_cron() -> None:
"""Скрипт без вызова — мёртвый код; страховка от проверки пустоты."""
assert SCRIPT.is_file(), "ops/backup-env-offbox.sh пропал"
assert "backup-env-offbox.sh" in CRON.read_text(encoding="utf-8"), (
"скрипт не вызывается из crontab-poincare.cron — копия делаться не будет"
)
def test_refuses_to_run_without_passphrase() -> None:
"""Без парольной фразы — выход с ошибкой, а не выгрузка открытого файла."""
guard = re.search(
r'if \[\[ -z "\$\{ENV_OFFBOX_PASSPHRASE:-\}" \]\]; then(.+?)\bfi\b', _text(), re.S
)
assert guard, "нет проверки на заданность парольной фразы"
assert "exit 1" in guard.group(1), (
"проверка есть, но скрипт продолжает работу — файл уедет открытым"
)
def test_encryption_happens_before_upload() -> None:
"""Шифрование обязано стоять ДО выгрузки, иначе порядок ничего не гарантирует."""
code = _code()
assert code.index("--symmetric") < code.index("s3 cp"), "выгрузка идёт раньше шифрования"
assert "--cipher-algo AES256" in code
def test_passphrase_never_passed_as_command_line_argument() -> None:
"""Фраза уходит через дескриптор, а не аргументом.
`--passphrase '<фраза>'` в командной строке видна в `ps` любому пользователю
машины — на хосте, где крутится прод, это не теоретическая придирка.
"""
code = _code()
assert "--passphrase-fd 3" in code, "фраза не передаётся через дескриптор"
assert not re.search(r'--passphrase\s+"?\$\{?ENV_OFFBOX_PASSPHRASE', code), (
"фраза передаётся аргументом командной строки — видна в ps"
)
def test_encrypted_file_is_verified_by_decrypting_it_back() -> None:
"""Проверка обратным чтением — иначе годами возили бы нечитаемый мусор.
Отказ такой копии обнаруживается ровно в тот момент, когда она понадобилась,
то есть в худший из возможных.
"""
code = _code()
assert "--decrypt" in code, "нет проверки расшифровкой после шифрования"
assert code.index("--symmetric") < code.index("--decrypt") < code.index("s3 cp"), (
"проверка расшифровкой стоит не между шифрованием и выгрузкой"
)