Compare commits

..

1 Commits

Author SHA1 Message Date
f8f01c4b2e feat: add hardened atomic staging deployment
All checks were successful
Quality gates / quality (pull_request) Successful in 1m28s
2026-08-21 14:49:28 +00:00
5 changed files with 210 additions and 37 deletions

View File

@ -16,7 +16,7 @@ Environment=TIMMY_BASE_PATH=/timmy-staging
Environment=TIMMY_AGENT_ENABLED=false Environment=TIMMY_AGENT_ENABLED=false
Environment=TIMMY_VISION_ENABLED=false Environment=TIMMY_VISION_ENABLED=false
EnvironmentFile=/etc/timmy-staging.env EnvironmentFile=/etc/timmy-staging.env
ExecStart=/usr/bin/node server.mjs ExecStart=/usr/bin/env TIMMY_AGENT_ENABLED=false TIMMY_VISION_ENABLED=false /usr/local/lib/timmy-staging/node server.mjs
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s
TimeoutStartSec=30s TimeoutStartSec=30s

View File

@ -21,6 +21,10 @@ No DNS change is required for the approved subpage deployment. The private URL i
These commands are reference commands for an approved maintenance window; they have not been run by this change: These commands are reference commands for an approved maintenance window; they have not been run by this change:
```bash ```bash
NODE_SOURCE="$(readlink -f "$(command -v node)")"
"$NODE_SOURCE" --version # must report v22.x before installation
sudo install -D -o root -g root -m 755 "$NODE_SOURCE" /usr/local/lib/timmy-staging/node
/usr/local/lib/timmy-staging/node --version
sudo useradd --system --home-dir /nonexistent --no-create-home --shell /usr/sbin/nologin timmy-staging sudo useradd --system --home-dir /nonexistent --no-create-home --shell /usr/sbin/nologin timmy-staging
sudo install -d -o root -g root -m 755 /opt/timmy-staging/releases sudo install -d -o root -g root -m 755 /opt/timmy-staging/releases
sudo install -d -o timmy-staging -g timmy-staging -m 700 /var/lib/timmy-staging sudo install -d -o timmy-staging -g timmy-staging -m 700 /var/lib/timmy-staging
@ -30,7 +34,7 @@ sudo chmod 600 /etc/timmy-staging.env
sudo systemctl daemon-reload sudo systemctl daemon-reload
``` ```
Edit only `TIMMY_RELEASE_TAG` and `TIMMY_RELEASE_COMMIT` for the selected artifact. Leave `HOST=127.0.0.1`, `PORT=4174`, `TIMMY_BASE_PATH=/timmy-staging`, `TIMMY_AGENT_ENABLED=false`, and `TIMMY_VISION_ENABLED=false`. The environment file must stay root-owned mode 600 and absent from release archives. Edit only `TIMMY_RELEASE_TAG` and `TIMMY_RELEASE_COMMIT` for the selected artifact. The unit deliberately applies `TIMMY_AGENT_ENABLED=false` and `TIMMY_VISION_ENABLED=false` on the `ExecStart` command after loading the environment file, so values in that file cannot enable either subsystem. Leave the loopback host, port, and base path unchanged. The environment file must stay root-owned mode 600 and absent from release archives. The copied Node 22 executable is root-owned at `/usr/local/lib/timmy-staging/node`, outside every home directory, so `ProtectHome=true` remains enforceable; replace it only through a separately reviewed Node upgrade.
Generate Caddy basic-auth material interactively (for example, `caddy hash-password`) and inject it as `TIMMY_STAGING_PASSWORD_HASH`; do not commit the output. Merge the staging handle before the existing catchall, validate a temporary complete config, then use `caddy reload` rather than restarting unrelated services. Generate Caddy basic-auth material interactively (for example, `caddy hash-password`) and inject it as `TIMMY_STAGING_PASSWORD_HASH`; do not commit the output. Merge the staging handle before the existing catchall, validate a temporary complete config, then use `caddy reload` rather than restarting unrelated services.
@ -44,7 +48,7 @@ python3 scripts/deploy_staging.py --dry-run promote \
--sha256 64_LOWERCASE_HEX_CHARACTERS --commit 40_LOWERCASE_HEX_CHARACTERS --sha256 64_LOWERCASE_HEX_CHARACTERS --commit 40_LOWERCASE_HEX_CHARACTERS
``` ```
Then run the same command without `--dry-run`. Promotion verifies the expected SHA-256 before opening the tar, rejects unsafe members and forbidden artifacts, extracts once to `/opt/timmy-staging/releases/<commit>`, and never overwrites that directory. It atomically swaps `current`, restarts only `timmy-staging.service`, checks bounded health, and invokes `npm run test:staging-smoke` as an argv array with `shell=False`. Restart, health, or smoke failure automatically restores and verifies the prior symlink. Then run the same command without `--dry-run`. Promotion opens only a non-symlink regular source, copies it while hashing into a mode-400 file in a private temporary directory, and inspects and extracts only that verified copy before cleaning it. It rejects unsafe members and forbidden artifacts, extracts once to `/opt/timmy-staging/releases/<commit>`, and never overwrites that directory. It atomically swaps `current`, restarts only `timmy-staging.service`, checks bounded health, and invokes `npm run test:staging-smoke` as an argv array with `shell=False`. Restart, health, or smoke failure automatically restores and verifies the prior symlink. If there is no prior release, the tool removes `current` and stops the service instead of restarting it against a missing path; the failed release remains unreferenced as inert immutable evidence for operator review.
The operator must update `/etc/timmy-staging.env` release identity to the same reviewed tag and commit before promotion. A narrow wrapper/sudo policy may set the smoke environment without granting arbitrary command execution: The operator must update `/etc/timmy-staging.env` release identity to the same reviewed tag and commit before promotion. A narrow wrapper/sudo policy may set the smoke environment without granting arbitrary command execution:

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
import hashlib import hashlib
import json import json
@ -11,9 +12,11 @@ from pathlib import Path, PurePosixPath
import re import re
import secrets import secrets
import shutil import shutil
import stat
import subprocess import subprocess
import sys import sys
import tarfile import tarfile
import tempfile
import time import time
from typing import Callable, Sequence from typing import Callable, Sequence
import urllib.error import urllib.error
@ -35,6 +38,7 @@ class DeploymentError(RuntimeError):
class DeploymentConfig: class DeploymentConfig:
root: Path root: Path
restart_command: tuple[str, ...] restart_command: tuple[str, ...]
stop_command: tuple[str, ...]
smoke_command: tuple[str, ...] smoke_command: tuple[str, ...]
health_url: str health_url: str
command_timeout: float = 180.0 command_timeout: float = 180.0
@ -65,15 +69,37 @@ def _validate_identity(tag: str, commit: str, expected_sha256: str) -> None:
raise DeploymentError("SHA-256 must be exactly 64 lowercase hexadecimal characters") raise DeploymentError("SHA-256 must be exactly 64 lowercase hexadecimal characters")
def sha256_file(path: Path) -> str: @contextmanager
digest = hashlib.sha256() def _verified_archive_copy(source_path: Path, expected_sha256: str):
"""Copy and hash an untrusted regular archive once, then yield the private copy."""
source_fd = -1
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
try: try:
with path.open("rb") as handle: source_fd = os.open(source_path, flags)
for chunk in iter(lambda: handle.read(1024 * 1024), b""): if not stat.S_ISREG(os.fstat(source_fd).st_mode):
raise DeploymentError("archive source must be a regular file, not a symlink or special file")
with tempfile.TemporaryDirectory(prefix="timmy-verified-archive-") as private_dir:
private_path = Path(private_dir) / "archive"
digest = hashlib.sha256()
with os.fdopen(source_fd, "rb", closefd=True) as source:
source_fd = -1
with private_path.open("xb") as destination:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk) digest.update(chunk)
destination.write(chunk)
destination.flush()
os.fsync(destination.fileno())
private_path.chmod(0o400)
if digest.hexdigest() != expected_sha256:
raise DeploymentError("archive SHA-256 mismatch; refusing extraction")
yield private_path
except DeploymentError:
raise
except OSError as error: except OSError as error:
raise DeploymentError(f"cannot read archive: {error.strerror or 'I/O error'}") from error raise DeploymentError(f"cannot stage archive: {error.strerror or 'I/O error'}") from error
return digest.hexdigest() finally:
if source_fd >= 0:
os.close(source_fd)
def _safe_parts(name: str) -> tuple[str, ...]: def _safe_parts(name: str) -> tuple[str, ...]:
@ -120,6 +146,8 @@ def inspect_archive(archive_path: Path, config: DeploymentConfig) -> tuple[list[
if total > config.max_total_bytes: if total > config.max_total_bytes:
raise DeploymentError("archive expanded size exceeds configured limit") raise DeploymentError("archive expanded size exceeds configured limit")
common_root = all_parts[0][0] if all(parts[0] == all_parts[0][0] for parts in all_parts) else None common_root = all_parts[0][0] if all(parts[0] == all_parts[0][0] for parts in all_parts) else None
if common_root and _is_forbidden((common_root,)):
raise DeploymentError(f"forbidden archive artifact: {common_root}")
for parts in all_parts: for parts in all_parts:
relative = parts[1:] if common_root else parts relative = parts[1:] if common_root else parts
if not relative: if not relative:
@ -149,7 +177,19 @@ def _extract_validated(archive_path: Path, destination: Path, members: list[tarf
target.chmod(0o755 if member.mode & 0o111 else 0o644) target.chmod(0o755 if member.mode & 0o111 else 0o644)
def _ensure_deployment_root(config: DeploymentConfig, *, create: bool) -> None:
if config.root.is_symlink():
raise DeploymentError("deployment root must not be a symlink")
if config.root.exists() and not config.root.is_dir():
raise DeploymentError("deployment root is not a directory")
if create:
config.root.mkdir(parents=True, exist_ok=True, mode=0o755)
if config.root.is_symlink() or not config.root.is_dir():
raise DeploymentError("deployment root is not a safe directory")
def _ensure_releases_directory(config: DeploymentConfig, *, create: bool) -> None: def _ensure_releases_directory(config: DeploymentConfig, *, create: bool) -> None:
_ensure_deployment_root(config, create=create)
if config.releases.is_symlink(): if config.releases.is_symlink():
raise DeploymentError("releases directory must not be a symlink") raise DeploymentError("releases directory must not be a symlink")
if config.releases.exists() and not config.releases.is_dir(): if config.releases.exists() and not config.releases.is_dir():
@ -177,7 +217,9 @@ def _current_commit(config: DeploymentConfig) -> str | None:
def _atomic_point(config: DeploymentConfig, commit: str | None) -> None: def _atomic_point(config: DeploymentConfig, commit: str | None) -> None:
config.root.mkdir(parents=True, exist_ok=True) _ensure_releases_directory(config, create=False)
if config.current.exists() and not config.current.is_symlink():
raise DeploymentError("current exists but is not a symlink")
temporary = config.root / f".current-{os.getpid()}-{secrets.token_hex(6)}" temporary = config.root / f".current-{os.getpid()}-{secrets.token_hex(6)}"
try: try:
if commit is None: if commit is None:
@ -229,19 +271,22 @@ def promote(*, config: DeploymentConfig, tag: str, archive: Path, expected_sha25
run_command: RunCommand = _run_argv, health_check: HealthCheck = poll_health) -> dict: run_command: RunCommand = _run_argv, health_check: HealthCheck = poll_health) -> dict:
archive = Path(archive) archive = Path(archive)
_validate_identity(tag, commit, expected_sha256) _validate_identity(tag, commit, expected_sha256)
actual_sha256 = sha256_file(archive) _ensure_deployment_root(config, create=False)
if actual_sha256 != expected_sha256: with _verified_archive_copy(archive, expected_sha256) as verified_archive:
raise DeploymentError("archive SHA-256 mismatch; refusing extraction") members, common_root = inspect_archive(verified_archive, config)
members, common_root = inspect_archive(archive, config)
final = config.releases / commit final = config.releases / commit
if final.exists() or final.is_symlink(): if final.exists() or final.is_symlink():
raise DeploymentError(f"immutable release already exists: {commit}") raise DeploymentError(f"immutable release already exists: {commit}")
previous = _current_commit(config) previous = _current_commit(config)
_ensure_releases_directory(config, create=True) _ensure_releases_directory(config, create=True)
pending = config.releases / f".pending-{commit}-{secrets.token_hex(6)}" pending = config.releases / f".pending-{commit}-{secrets.token_hex(6)}"
if pending.exists() or pending.is_symlink():
raise DeploymentError("pending release boundary already exists")
pending.mkdir(mode=0o755) pending.mkdir(mode=0o755)
if pending.is_symlink() or not pending.is_dir():
raise DeploymentError("pending release boundary is not a safe directory")
try: try:
_extract_validated(archive, pending, members, common_root) _extract_validated(verified_archive, pending, members, common_root)
metadata = {"schemaVersion": 1, "tag": tag, "commit": commit, "sha256": expected_sha256} metadata = {"schemaVersion": 1, "tag": tag, "commit": commit, "sha256": expected_sha256}
(pending / ".timmy-release.json").write_text(json.dumps(metadata, sort_keys=True) + "\n", encoding="utf-8") (pending / ".timmy-release.json").write_text(json.dumps(metadata, sort_keys=True) + "\n", encoding="utf-8")
os.replace(pending, final) os.replace(pending, final)
@ -257,9 +302,11 @@ def promote(*, config: DeploymentConfig, tag: str, archive: Path, expected_sha25
if previous is not None: if previous is not None:
_verify(config, previous, run_command, health_check, smoke=False) _verify(config, previous, run_command, health_check, smoke=False)
else: else:
run_command(config.restart_command, check=True, text=True, capture_output=True, shell=False, timeout=config.command_timeout) run_command(config.stop_command, check=True, text=True, capture_output=True, shell=False, timeout=config.command_timeout)
except Exception as rollback_error: except Exception as rollback_error:
raise DeploymentError("promotion verification failed and rollback verification also failed") from rollback_error raise DeploymentError("promotion verification failed and rollback verification also failed") from rollback_error
if previous is None:
raise DeploymentError("promotion verification failed; no prior release; service stopped") from error
raise DeploymentError("promotion verification failed; prior release restored") from error raise DeploymentError("promotion verification failed; prior release restored") from error
return {"ok": True, "action": "promote", **metadata, "previousCommit": previous} return {"ok": True, "action": "promote", **metadata, "previousCommit": previous}
@ -331,6 +378,7 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--root", type=Path, default=Path(os.environ.get("TIMMY_STAGING_ROOT", "/opt/timmy-staging"))) parser.add_argument("--root", type=Path, default=Path(os.environ.get("TIMMY_STAGING_ROOT", "/opt/timmy-staging")))
parser.add_argument("--dry-run", action="store_true", help="validate and print the intended action without mutation or commands") parser.add_argument("--dry-run", action="store_true", help="validate and print the intended action without mutation or commands")
parser.add_argument("--restart-command", default=os.environ.get("TIMMY_STAGING_RESTART_COMMAND", '["systemctl","restart","timmy-staging.service"]')) parser.add_argument("--restart-command", default=os.environ.get("TIMMY_STAGING_RESTART_COMMAND", '["systemctl","restart","timmy-staging.service"]'))
parser.add_argument("--stop-command", default=os.environ.get("TIMMY_STAGING_STOP_COMMAND", '["systemctl","stop","timmy-staging.service"]'))
parser.add_argument("--smoke-command", default=os.environ.get("TIMMY_STAGING_SMOKE_COMMAND", '["npm","run","test:staging-smoke"]')) parser.add_argument("--smoke-command", default=os.environ.get("TIMMY_STAGING_SMOKE_COMMAND", '["npm","run","test:staging-smoke"]'))
parser.add_argument("--health-url", default=os.environ.get("TIMMY_STAGING_HEALTH_URL", "http://127.0.0.1:4174/timmy-staging/api/healthz")) parser.add_argument("--health-url", default=os.environ.get("TIMMY_STAGING_HEALTH_URL", "http://127.0.0.1:4174/timmy-staging/api/healthz"))
subparsers = parser.add_subparsers(dest="command", required=True) subparsers = parser.add_subparsers(dest="command", required=True)
@ -351,6 +399,7 @@ def main(argv: Sequence[str] | None = None) -> int:
config = DeploymentConfig( config = DeploymentConfig(
root=args.root, root=args.root,
restart_command=_json_argv(args.restart_command, "restart command"), restart_command=_json_argv(args.restart_command, "restart command"),
stop_command=_json_argv(args.stop_command, "stop command"),
smoke_command=_json_argv(args.smoke_command, "smoke command"), smoke_command=_json_argv(args.smoke_command, "smoke command"),
health_url=args.health_url, health_url=args.health_url,
) )
@ -359,10 +408,9 @@ def main(argv: Sequence[str] | None = None) -> int:
elif args.command == "promote": elif args.command == "promote":
if args.dry_run: if args.dry_run:
_validate_identity(args.tag, args.commit, args.sha256) _validate_identity(args.tag, args.commit, args.sha256)
actual = sha256_file(args.archive) _ensure_deployment_root(config, create=False)
if actual != args.sha256: with _verified_archive_copy(args.archive, args.sha256) as verified_archive:
raise DeploymentError("archive SHA-256 mismatch; refusing extraction") members, wrapper = inspect_archive(verified_archive, config)
members, wrapper = inspect_archive(args.archive, config)
result = {"ok": True, "dryRun": True, "action": "promote", "commit": args.commit, "tag": args.tag, "members": len(members), "wrapper": wrapper} result = {"ok": True, "dryRun": True, "action": "promote", "commit": args.commit, "tag": args.tag, "members": len(members), "wrapper": wrapper}
else: else:
result = promote(config=config, tag=args.tag, archive=args.archive, expected_sha256=args.sha256, commit=args.commit) result = promote(config=config, tag=args.tag, archive=args.archive, expected_sha256=args.sha256, commit=args.commit)

View File

@ -1,6 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
const servicePath = new URL('../deploy/timmy-staging.service', import.meta.url); const servicePath = new URL('../deploy/timmy-staging.service', import.meta.url);
const envPath = new URL('../deploy/timmy-staging.env.example', import.meta.url); const envPath = new URL('../deploy/timmy-staging.env.example', import.meta.url);
@ -19,11 +20,29 @@ test('systemd template runs a dedicated loopback-only agent-disabled service', a
'Environment=TIMMY_BASE_PATH=/timmy-staging', 'Environment=TIMMY_BASE_PATH=/timmy-staging',
'Environment=TIMMY_AGENT_ENABLED=false', 'Environment=TIMMY_AGENT_ENABLED=false',
]) assert.match(service, new RegExp(`^${directive.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'), directive); ]) assert.match(service, new RegExp(`^${directive.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'), directive);
assert.match(service, /^ExecStart=\/usr\/bin\/node server\.mjs$/m); assert.match(service, /^ExecStart=\/usr\/bin\/env TIMMY_AGENT_ENABLED=false TIMMY_VISION_ENABLED=false \/usr\/local\/lib\/timmy-staging\/node server\.mjs$/m);
assert.doesNotMatch(service, /0\.0\.0\.0|TIMMY_AGENT_ENABLED=true/); assert.doesNotMatch(service, /ExecStart=\/usr\/(?:local\/)?bin\/node|0\.0\.0\.0|TIMMY_AGENT_ENABLED=true/);
assert.doesNotMatch(service, literalSecret); assert.doesNotMatch(service, literalSecret);
}); });
test('systemd command-level environment keeps agent and vision disabled after EnvironmentFile overrides', async () => {
const service = await readFile(servicePath, 'utf8');
const environmentFileIndex = service.indexOf('EnvironmentFile=');
const execLine = service.match(/^ExecStart=(.+)$/m)?.[1];
assert.ok(execLine, 'ExecStart must exist');
assert.ok(environmentFileIndex < service.indexOf(`ExecStart=${execLine}`), 'EnvironmentFile must be loaded before command overrides');
const argv = execLine.trim().split(/\s+/);
assert.equal(argv.shift(), '/usr/bin/env');
const assignments = argv.filter(value => /^TIMMY_(?:AGENT|VISION)_ENABLED=/.test(value));
assert.deepEqual(assignments, ['TIMMY_AGENT_ENABLED=false', 'TIMMY_VISION_ENABLED=false']);
const probe = spawnSync('/usr/bin/env', [
...assignments, process.execPath, '-e',
'process.stdout.write(`${process.env.TIMMY_AGENT_ENABLED},${process.env.TIMMY_VISION_ENABLED}`)',
], { env: { ...process.env, TIMMY_AGENT_ENABLED: 'true', TIMMY_VISION_ENABLED: 'true' }, encoding: 'utf8' });
assert.equal(probe.status, 0, probe.stderr);
assert.equal(probe.stdout, 'false,false');
});
test('systemd template applies filesystem privilege process and resource confinement', async () => { test('systemd template applies filesystem privilege process and resource confinement', async () => {
const service = await readFile(servicePath, 'utf8'); const service = await readFile(servicePath, 'utf8');
for (const directive of [ for (const directive of [
@ -74,8 +93,12 @@ test('runbook covers safe installation operation verification rollback backup an
'Rollback', 'Backup', 'Remove staging', 'Template validation', 'Rollback', 'Backup', 'Remove staging', 'Template validation',
]) assert.match(runbook, new RegExp(`^## .*${heading}`, 'mi'), heading); ]) assert.match(runbook, new RegExp(`^## .*${heading}`, 'mi'), heading);
assert.match(runbook, /sha256/i); assert.match(runbook, /sha256/i);
assert.match(runbook, /install -D -o root -g root -m 755[^\n]+\/usr\/local\/lib\/timmy-staging\/node/);
assert.match(runbook, /\/usr\/local\/lib\/timmy-staging\/node --version/);
assert.match(runbook, /chmod 600|install -m 600/); assert.match(runbook, /chmod 600|install -m 600/);
assert.match(runbook, /systemctl restart timmy-staging\.service/); assert.match(runbook, /systemctl restart timmy-staging\.service/);
assert.match(runbook, /no prior release[^.]*stops[^.]*service/i);
assert.match(runbook, /failed release[^.]*inert[^.]*evidence/i);
assert.match(runbook, /journalctl -u timmy-staging\.service/); assert.match(runbook, /journalctl -u timmy-staging\.service/);
assert.match(runbook, /do not.*live|approval/i); assert.match(runbook, /do not.*live|approval/i);
assert.doesNotMatch(runbook, literalSecret); assert.doesNotMatch(runbook, literalSecret);

View File

@ -12,6 +12,7 @@ import sys
import tarfile import tarfile
import tempfile import tempfile
import unittest import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "deploy_staging.py" SCRIPT = ROOT / "scripts" / "deploy_staging.py"
@ -63,6 +64,7 @@ class DeployTests(unittest.TestCase):
self.config = self.deploy.DeploymentConfig( self.config = self.deploy.DeploymentConfig(
root=self.root, root=self.root,
restart_command=("fixture-restart",), restart_command=("fixture-restart",),
stop_command=("fixture-stop",),
smoke_command=("fixture-smoke",), smoke_command=("fixture-smoke",),
health_url="http://127.0.0.1:4174/api/healthz", health_url="http://127.0.0.1:4174/api/healthz",
command_timeout=1.0, command_timeout=1.0,
@ -116,6 +118,37 @@ class DeployTests(unittest.TestCase):
self.promote(archive, "0" * 64) self.promote(archive, "0" * 64)
self.assertFalse((self.root / "releases").exists()) self.assertFalse((self.root / "releases").exists())
def test_verified_private_archive_copy_is_used_after_caller_archive_mutates(self):
archive, digest = self.archive("mutable.tar.gz")
evil = Path(self.tmp.name) / "evil.tar.gz"
make_archive(evil, [
("timmy-release/", b"", "dir"),
("timmy-release/server.mjs", b"EVIL\n", "file"),
])
real_inspect = self.deploy.inspect_archive
inspected_paths = []
def mutate_then_inspect(path, config):
inspected_paths.append(Path(path))
archive.write_bytes(evil.read_bytes())
return real_inspect(path, config)
with mock.patch.object(self.deploy, "inspect_archive", side_effect=mutate_then_inspect):
self.promote(archive, digest)
release = self.root / "releases" / COMMIT_B
self.assertNotEqual(inspected_paths, [archive])
self.assertEqual((release / "server.mjs").read_text(), "console.log('ok')\n")
self.assertTrue(all(not path.exists() for path in inspected_paths), "verified temp archive must be cleaned")
def test_archive_source_must_be_a_nonsymlink_regular_file(self):
archive, digest = self.archive("regular.tar.gz")
symlink = Path(self.tmp.name) / "archive-link.tar.gz"
symlink.symlink_to(archive)
for source in (symlink, Path(self.tmp.name)):
with self.subTest(source=source), self.assertRaisesRegex(self.deploy.DeploymentError, "regular file|stage archive"):
self.promote(source, digest)
def test_absolute_and_traversal_paths_are_rejected(self): def test_absolute_and_traversal_paths_are_rejected(self):
for index, unsafe in enumerate(("/etc/passwd", "root/../../escape", "../escape", "root//double")): for index, unsafe in enumerate(("/etc/passwd", "root/../../escape", "../escape", "root//double")):
archive, digest = self.archive(f"unsafe-{index}.tar.gz", [(unsafe, b"bad", "file")]) archive, digest = self.archive(f"unsafe-{index}.tar.gz", [(unsafe, b"bad", "file")])
@ -146,6 +179,18 @@ class DeployTests(unittest.TestCase):
with self.subTest(name=name), self.assertRaisesRegex(self.deploy.DeploymentError, "forbidden archive artifact"): with self.subTest(name=name), self.assertRaisesRegex(self.deploy.DeploymentError, "forbidden archive artifact"):
self.promote(archive, digest) self.promote(archive, digest)
def test_forbidden_common_roots_are_rejected_case_insensitively_but_release_wrapper_is_allowed(self):
for index, root in enumerate((".git", "ViDeO", "ARTIFACTS", "Credentials")):
archive, _ = self.archive(f"forbidden-root-{index}.tar.gz", [
(f"{root}/", b"", "dir"),
(f"{root}/server.mjs", b"evil", "file"),
])
with self.subTest(root=root), self.assertRaisesRegex(self.deploy.DeploymentError, "forbidden archive artifact"):
self.deploy.inspect_archive(archive, self.config)
archive, _ = self.archive("normal-wrapper.tar.gz")
_, wrapper = self.deploy.inspect_archive(archive, self.config)
self.assertEqual(wrapper, "timmy-release")
def test_release_root_symlink_is_rejected(self): def test_release_root_symlink_is_rejected(self):
outside = Path(self.tmp.name) / "outside" outside = Path(self.tmp.name) / "outside"
outside.mkdir() outside.mkdir()
@ -155,6 +200,41 @@ class DeployTests(unittest.TestCase):
self.promote(archive, digest) self.promote(archive, digest)
self.assertEqual(list(outside.iterdir()), []) self.assertEqual(list(outside.iterdir()), [])
def test_deployment_root_symlink_is_rejected_before_writing_outside(self):
outside = Path(self.tmp.name) / "outside-root"
outside.mkdir()
self.root.rmdir()
self.root.symlink_to(outside, target_is_directory=True)
archive, digest = self.archive()
with self.assertRaisesRegex(self.deploy.DeploymentError, "deployment root"):
self.promote(archive, digest)
self.assertEqual(list(outside.iterdir()), [])
def test_deployment_root_non_directory_is_rejected(self):
self.root.rmdir()
self.root.write_text("not a directory")
archive, digest = self.archive()
with self.assertRaisesRegex(self.deploy.DeploymentError, "deployment root is not a directory"):
self.promote(archive, digest)
def test_current_and_pending_symlink_boundaries_are_rejected_before_extraction(self):
outside = Path(self.tmp.name) / "outside-boundary"
outside.mkdir()
(self.root / "releases").mkdir()
(self.root / "current").symlink_to(outside, target_is_directory=True)
archive, digest = self.archive("current-boundary.tar.gz")
with self.assertRaisesRegex(self.deploy.DeploymentError, "current symlink escapes"):
self.promote(archive, digest)
self.assertEqual(list(outside.iterdir()), [])
(self.root / "current").unlink()
pending = self.root / "releases" / f".pending-{COMMIT_B}-fixed"
pending.symlink_to(outside, target_is_directory=True)
with mock.patch.object(self.deploy.secrets, "token_hex", return_value="fixed"):
with self.assertRaisesRegex(self.deploy.DeploymentError, "pending release boundary"):
self.promote(archive, digest)
self.assertEqual(list(outside.iterdir()), [])
def test_valid_archive_extracts_to_commit_release_and_is_never_overwritten(self): def test_valid_archive_extracts_to_commit_release_and_is_never_overwritten(self):
archive, digest = self.archive() archive, digest = self.archive()
result = self.promote(archive, digest) result = self.promote(archive, digest)
@ -201,6 +281,24 @@ class DeployTests(unittest.TestCase):
self.assertEqual((root / "current").resolve(), root / "releases" / COMMIT_A) self.assertEqual((root / "current").resolve(), root / "releases" / COMMIT_A)
self.assertGreaterEqual(calls.count(("fixture-restart",)), 2) self.assertGreaterEqual(calls.count(("fixture-restart",)), 2)
def test_first_promotion_failure_removes_current_and_stops_service_without_restart(self):
archive, digest = self.archive("first-failure.tar.gz")
calls = []
def run(argv, **kwargs):
calls.append(tuple(argv))
if tuple(argv) == ("fixture-smoke",):
raise subprocess.CalledProcessError(1, argv)
return subprocess.CompletedProcess(argv, 0, "", "")
with self.assertRaisesRegex(self.deploy.DeploymentError, "no prior release; service stopped"):
self.promote(archive, digest, run_command=run)
self.assertFalse((self.root / "current").exists())
self.assertFalse((self.root / "current").is_symlink())
self.assertEqual(calls, [("fixture-restart",), ("fixture-smoke",), ("fixture-stop",)])
self.assertTrue((self.root / "releases" / COMMIT_B).is_dir(), "failed release remains inert evidence")
def test_rollback_requires_valid_immutable_release_and_restarts_and_checks_health(self): def test_rollback_requires_valid_immutable_release_and_restarts_and_checks_health(self):
self.seed_release(COMMIT_A) self.seed_release(COMMIT_A)
self.seed_release(COMMIT_B) self.seed_release(COMMIT_B)