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
Четвёртый пункт приёмки #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
97 lines
5.4 KiB
Python
97 lines
5.4 KiB
Python
"""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"), (
|
||
"проверка расшифровкой стоит не между шифрованием и выгрузкой"
|
||
)
|