feat(v2): complete v2 reimplementation
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
"""Static checks for the production Compose packaging slice."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from backup_tool.cli import build_parser
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_health_command_selects_an_explicit_runtime_role() -> None:
|
||||
parsed = build_parser().parse_args(["health", "worker"])
|
||||
|
||||
assert parsed.role == "health"
|
||||
assert parsed.health_role == "worker"
|
||||
|
||||
|
||||
def test_runtime_images_are_pinned_non_root_and_exclude_database_clients() -> None:
|
||||
runtime = (ROOT / "Dockerfile").read_text()
|
||||
proxy = (ROOT / "frontend" / "Dockerfile").read_text()
|
||||
|
||||
for dockerfile in (runtime, proxy):
|
||||
from_lines = [line for line in dockerfile.splitlines() if line.startswith("FROM ")]
|
||||
assert from_lines
|
||||
assert all("@sha256:" in line for line in from_lines)
|
||||
|
||||
assert "USER backup-tool:backup-tool" in runtime
|
||||
assert 'ENTRYPOINT ["backup-tool"]' in runtime
|
||||
assert 'CMD ["web"]' in runtime
|
||||
assert "USER 10001:0" in proxy
|
||||
assert not re.search(r"\b(pg_dump|mysqldump|postgresql-client|mysql-client)\b", runtime)
|
||||
|
||||
|
||||
def test_compose_runs_one_isolated_role_per_service_without_reload() -> None:
|
||||
compose = (ROOT / "docker-compose.yml").read_text()
|
||||
|
||||
for role in ("web", "scheduler", "worker", "migrate", "admin"):
|
||||
assert f" {role}:" in compose
|
||||
for command in (
|
||||
'["web"]',
|
||||
'["scheduler"]',
|
||||
'["worker"]',
|
||||
'["migrate", "upgrade"]',
|
||||
'["admin", "--help"]',
|
||||
):
|
||||
assert command in compose
|
||||
assert "--reload" not in compose
|
||||
assert "backup-tool-runtime:/run/backup-tool" in compose
|
||||
assert '["CMD", "backup-tool", "health", "web"]' in compose
|
||||
assert '["CMD", "backup-tool", "health", "scheduler"]' in compose
|
||||
assert '["CMD", "backup-tool", "health", "worker"]' in compose
|
||||
|
||||
|
||||
def test_proxy_is_the_only_published_endpoint_and_uses_same_origin_socket() -> None:
|
||||
compose = (ROOT / "docker-compose.yml").read_text()
|
||||
nginx = (ROOT / "frontend" / "nginx.conf").read_text()
|
||||
|
||||
assert compose.count(" ports:") == 1
|
||||
assert '"127.0.0.1:${BACKUP_TOOL_PORT:-8080}:8080"' in compose
|
||||
assert '"${BACKUP_TOOL_PORT:-8080}:8080"' not in compose
|
||||
assert "server unix:/run/backup-tool/web.sock;" in nginx
|
||||
assert "location /api/" in nginx
|
||||
assert "location = /readyz" in nginx
|
||||
assert "location = /livez" in nginx
|
||||
assert "location = /metrics" in nginx
|
||||
assert "proxy_pass http://backup_tool_web;" in nginx
|
||||
|
||||
|
||||
def test_operational_artifacts_cover_sbom_provenance_and_recovery() -> None:
|
||||
assert (ROOT / "docs/release/m14-sbom.json").is_file()
|
||||
assert (ROOT / "docs/release/m14-provenance.md").is_file()
|
||||
for runbook in (
|
||||
"metadata.md",
|
||||
"repositories.md",
|
||||
"keys.md",
|
||||
"upgrade.md",
|
||||
"disaster-recovery.md",
|
||||
"observability.md",
|
||||
):
|
||||
assert (ROOT / "docs/runbooks" / runbook).is_file()
|
||||
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from backup_tool.security.repository_crypto import (
|
||||
RepositoryKeyError,
|
||||
decrypt_object,
|
||||
encrypt_object,
|
||||
object_aad,
|
||||
)
|
||||
|
||||
|
||||
def test_encrypted_object_hides_plaintext_and_rejects_tampering() -> None:
|
||||
key = os.urandom(32)
|
||||
aad = object_aad("repository", "epoch", "blob", "identity")
|
||||
stored = encrypt_object(key, aad, b"secret-content")
|
||||
assert b"secret-content" not in stored
|
||||
assert decrypt_object(key, aad, stored) == b"secret-content"
|
||||
with pytest.raises(RepositoryKeyError):
|
||||
decrypt_object(key, aad, stored[:-1] + bytes([stored[-1] ^ 1]))
|
||||
@@ -0,0 +1,160 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from backup_tool.cli import build_alembic_config, build_parser
|
||||
from backup_tool.cli import main as cli_main
|
||||
from backup_tool.db.engine import create_engine
|
||||
from backup_tool.db.models import Repository, RepositoryDataKeyEpoch
|
||||
from backup_tool.repository import initialize
|
||||
from backup_tool.security.recovery_bundle import (
|
||||
RecoveryBundleError,
|
||||
RecoveryBundlePathError,
|
||||
decrypt_bundle,
|
||||
encrypt_bundle,
|
||||
write_bundle_exclusive,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from tests.conftest import make_settings
|
||||
|
||||
PASSPHRASE = b"correct horse battery staple"
|
||||
|
||||
|
||||
def _passphrase_fd(value: bytes) -> int:
|
||||
read_fd, write_fd = os.pipe()
|
||||
os.write(write_fd, value + b"\n")
|
||||
os.close(write_fd)
|
||||
return read_fd
|
||||
|
||||
|
||||
def test_recovery_bundle_rejects_wrong_passphrase_tampering_and_invalid_kdf() -> None:
|
||||
bundle = encrypt_bundle({"catalog": {"version": 1}, "keys": []}, PASSPHRASE)
|
||||
tampered = bundle[:-1] + bytes([bundle[-1] ^ 1])
|
||||
unsupported_kdf = bundle[:6] + b"\x02" + bundle[7:]
|
||||
|
||||
for encoded, passphrase in (
|
||||
(bundle, b"wrong passphrase"),
|
||||
(tampered, PASSPHRASE),
|
||||
(unsupported_kdf, PASSPHRASE),
|
||||
):
|
||||
with pytest.raises(RecoveryBundleError, match="^recovery bundle is invalid$"):
|
||||
decrypt_bundle(encoded, passphrase)
|
||||
|
||||
|
||||
def test_recovery_bundle_output_is_exclusive_and_does_not_follow_symlinks(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
bundle = encrypt_bundle({"catalog": {"version": 1}, "keys": []}, PASSPHRASE)
|
||||
existing = tmp_path / "existing.btrec"
|
||||
existing.write_bytes(b"keep")
|
||||
|
||||
with pytest.raises(RecoveryBundlePathError, match="^recovery bundle output is unsafe$"):
|
||||
write_bundle_exclusive(existing, bundle)
|
||||
assert existing.read_bytes() == b"keep"
|
||||
|
||||
target = tmp_path / "target.btrec"
|
||||
target.write_bytes(b"keep")
|
||||
symlink = tmp_path / "link.btrec"
|
||||
symlink.symlink_to(target)
|
||||
with pytest.raises(RecoveryBundlePathError, match="^recovery bundle output is unsafe$"):
|
||||
write_bundle_exclusive(symlink, bundle)
|
||||
assert target.read_bytes() == b"keep"
|
||||
|
||||
|
||||
def test_cli_recovery_export_validate_uses_fd_and_hides_plaintext_keys(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
settings = make_settings(tmp_path)
|
||||
command.upgrade(build_alembic_config(settings), "head")
|
||||
initialized = initialize(settings, "encrypted", "none", "aes-256-gcm")
|
||||
assert initialized.data_key_id is not None
|
||||
assert initialized.data_key_path is not None
|
||||
|
||||
async def create_repository() -> None:
|
||||
engine = create_engine(settings)
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
try:
|
||||
async with sessions() as db:
|
||||
repository = Repository(
|
||||
name="encrypted",
|
||||
root=str(initialized.root),
|
||||
format_version=initialized.format_version,
|
||||
compression=initialized.compression,
|
||||
encryption=initialized.encryption,
|
||||
signing_key_id=initialized.signing_key_id,
|
||||
signing_public_key=initialized.signing_public_key,
|
||||
active_data_key_id=initialized.data_key_id,
|
||||
)
|
||||
db.add(repository)
|
||||
await db.flush()
|
||||
db.add(
|
||||
RepositoryDataKeyEpoch(
|
||||
repository_id=repository.id,
|
||||
key_id=initialized.data_key_id,
|
||||
state="active",
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(create_repository())
|
||||
output = tmp_path / "recovery.btrec"
|
||||
export_fd = _passphrase_fd(PASSPHRASE)
|
||||
try:
|
||||
assert (
|
||||
cli_main(
|
||||
[
|
||||
"admin",
|
||||
"recovery",
|
||||
"export",
|
||||
"--output",
|
||||
str(output),
|
||||
"--passphrase-fd",
|
||||
str(export_fd),
|
||||
],
|
||||
settings=settings,
|
||||
)
|
||||
== 0
|
||||
)
|
||||
finally:
|
||||
os.close(export_fd)
|
||||
export_output = capsys.readouterr().out
|
||||
bundle = output.read_bytes()
|
||||
assert bundle.startswith(b"BTREC\x01")
|
||||
assert initialized.data_key_path.read_bytes() not in bundle
|
||||
signing_key_path = (
|
||||
settings.data_dir / "repository-keys" / f"{initialized.repository_id}.ed25519"
|
||||
)
|
||||
assert signing_key_path.read_bytes() not in bundle
|
||||
assert "key" not in export_output.lower()
|
||||
|
||||
validate_fd = _passphrase_fd(PASSPHRASE)
|
||||
try:
|
||||
assert (
|
||||
cli_main(
|
||||
[
|
||||
"admin",
|
||||
"recovery",
|
||||
"validate",
|
||||
"--input",
|
||||
str(output),
|
||||
"--passphrase-fd",
|
||||
str(validate_fd),
|
||||
],
|
||||
settings=settings,
|
||||
)
|
||||
== 0
|
||||
)
|
||||
finally:
|
||||
os.close(validate_fd)
|
||||
assert capsys.readouterr().out == '{"repositories": 1, "status": "valid"}\n'
|
||||
|
||||
parser = build_parser()
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args(["admin", "recovery", "export", "--passphrase", "not-allowed"])
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
snapshot = importlib.import_module("backup_tool.snapshot")
|
||||
|
||||
|
||||
def file_entry(path: str = "data.txt") -> dict[str, object]:
|
||||
return {
|
||||
"path": path,
|
||||
"type": "file",
|
||||
"size": 1,
|
||||
"blob_digest": "a" * 64,
|
||||
"mode": 0o600,
|
||||
"mtime_ns": 0,
|
||||
"link_target": None,
|
||||
"metadata_support": ["mode", "mtime_ns"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entries",
|
||||
[
|
||||
[file_entry("../escape")],
|
||||
[file_entry("/absolute")],
|
||||
[file_entry("windows\\escape")],
|
||||
[file_entry(), file_entry()],
|
||||
[file_entry("file"), file_entry("file/child")],
|
||||
[
|
||||
{
|
||||
**file_entry("link"),
|
||||
"type": "symlink",
|
||||
"size": 0,
|
||||
"blob_digest": None,
|
||||
"link_target": "../escape",
|
||||
}
|
||||
],
|
||||
],
|
||||
)
|
||||
def test_restore_rejects_semantically_unsafe_signed_manifest_entries(
|
||||
entries: list[dict[str, object]],
|
||||
) -> None:
|
||||
manifest = {"entries": copy.deepcopy(entries)}
|
||||
|
||||
with pytest.raises(snapshot.SnapshotIntegrityError):
|
||||
snapshot._safe_restore_entries(manifest)
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from backup_tool.config import Settings
|
||||
|
||||
snapshot = importlib.import_module("backup_tool.snapshot")
|
||||
|
||||
|
||||
def settings_for(tmp_path: Path) -> Settings:
|
||||
key = tmp_path / "master.key"
|
||||
key.write_bytes(b"m6-restore-path-test-master-key-material")
|
||||
key.chmod(0o600)
|
||||
repositories = tmp_path / "repositories"
|
||||
sources = tmp_path / "sources"
|
||||
restores = tmp_path / "restores"
|
||||
for directory in (repositories, sources, restores):
|
||||
directory.mkdir()
|
||||
return Settings(
|
||||
data_dir=tmp_path,
|
||||
database_url=f"sqlite+aiosqlite:///{tmp_path / 'metadata.db'}",
|
||||
repository_roots=(repositories,),
|
||||
local_source_roots=(sources,),
|
||||
restore_roots=(restores,),
|
||||
master_key_file=key,
|
||||
min_free_bytes=1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["existing", "outside", "root"])
|
||||
def test_restore_destination_rejects_existing_or_outside_paths(tmp_path: Path, name: str) -> None:
|
||||
settings = settings_for(tmp_path)
|
||||
root = settings.restore_roots[0]
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
existing = root / "existing"
|
||||
existing.mkdir()
|
||||
destinations = {
|
||||
"existing": existing,
|
||||
"outside": outside / "restore",
|
||||
"root": root,
|
||||
}
|
||||
|
||||
with pytest.raises(snapshot.SnapshotError):
|
||||
snapshot.validate_restore_destination(settings, str(destinations[name]))
|
||||
|
||||
|
||||
def test_restore_strips_special_permission_bits(tmp_path: Path) -> None:
|
||||
target = tmp_path / "restored"
|
||||
target.write_bytes(b"content")
|
||||
|
||||
snapshot._apply_metadata(
|
||||
target,
|
||||
{"metadata_support": ["mode"], "mode": 0o7777},
|
||||
)
|
||||
|
||||
mode = stat.S_IMODE(target.stat().st_mode)
|
||||
assert mode == 0o777
|
||||
assert mode & (stat.S_ISUID | stat.S_ISGID | stat.S_ISVTX) == 0
|
||||
|
||||
|
||||
def test_restore_destination_rejects_a_symlinked_parent(tmp_path: Path) -> None:
|
||||
settings = settings_for(tmp_path)
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(settings.restore_roots[0] / "linked").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
with pytest.raises(snapshot.SnapshotError):
|
||||
snapshot.validate_restore_destination(
|
||||
settings, str(settings.restore_roots[0] / "linked" / "restore")
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from backup_tool.security.ssrf import (
|
||||
SSRFError,
|
||||
resolve_public_addresses,
|
||||
validate_webhook_url,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_private_or_mixed_answers_are_rejected() -> None:
|
||||
async def private(_host: str, _port: int) -> tuple[str, ...]:
|
||||
return ("8.8.8.8", "127.0.0.1")
|
||||
|
||||
with pytest.raises(SSRFError, match="non-public"):
|
||||
await resolve_public_addresses("hooks.example.test", 443, private)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"https://127.0.0.1/callback",
|
||||
"https://user:pass@hooks.example.test/callback",
|
||||
"https://hooks.example.test/callback#fragment",
|
||||
"ftp://hooks.example.test/callback",
|
||||
"https://hooks.example.test:22/callback",
|
||||
],
|
||||
)
|
||||
def test_webhook_url_rejects_bypasses(value: str) -> None:
|
||||
with pytest.raises(SSRFError):
|
||||
validate_webhook_url(value)
|
||||
Reference in New Issue
Block a user