651 lines
29 KiB
Python
651 lines
29 KiB
Python
import configparser
|
|
import importlib.util
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import stat
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
UPDATER_PATH = ROOT / "deploy" / "fail2ban" / "truf_caddy_admin_denylist.py"
|
|
SPEC = importlib.util.spec_from_file_location("truf_caddy_admin_denylist", UPDATER_PATH)
|
|
updater_module = importlib.util.module_from_spec(SPEC)
|
|
SPEC.loader.exec_module(updater_module)
|
|
|
|
|
|
def updater(tmp_path, runner=None, now=1_700_000_000):
|
|
state_dir = tmp_path / "state"
|
|
snippet_dir = tmp_path / "snippet"
|
|
state_dir.mkdir(mode=0o700)
|
|
snippet_dir.mkdir(mode=0o750)
|
|
snippet = snippet_dir / "admin-denylist.caddy"
|
|
snippet.write_text(updater_module.EMPTY_SNIPPET, encoding="ascii")
|
|
instance = updater_module.DenylistUpdater(
|
|
state_dir / "admin-denylist.json",
|
|
snippet,
|
|
project_directory=tmp_path / "project",
|
|
env_file=tmp_path / "edge.env",
|
|
runner=runner or (lambda command: True),
|
|
clock=lambda: now,
|
|
)
|
|
return instance, state_dir / "admin-denylist.json", snippet
|
|
|
|
|
|
def test_updater_persists_canonical_state_and_expires_automatically(tmp_path):
|
|
commands = []
|
|
instance, state_path, snippet_path = updater(
|
|
tmp_path, runner=lambda command: commands.append(command) or True,
|
|
)
|
|
result = instance.update("ban", "2001:db8:0:0::2")
|
|
assert result["bans"] == {"2001:db8::2": 1_700_086_400}
|
|
state = json.loads(state_path.read_text(encoding="ascii"))
|
|
assert state["bans"] == result["bans"]
|
|
assert re.fullmatch(r"[0-9a-f]{64}", state["applied_sha256"])
|
|
assert "2001:db8::2" in snippet_path.read_text(encoding="ascii")
|
|
assert any("validate" in command for command in commands)
|
|
assert any("reload" in command and "unix//run/caddy-admin.sock" in command for command in commands)
|
|
|
|
commands.clear()
|
|
persisted = updater_module.DenylistUpdater(
|
|
state_path, snippet_path, runner=lambda command: commands.append(command) or True,
|
|
clock=lambda: 1_700_086_401,
|
|
)
|
|
status = persisted.update("status")
|
|
assert status["expired"] == 1
|
|
assert status["bans"] == {}
|
|
assert snippet_path.read_text(encoding="ascii") == updater_module.EMPTY_SNIPPET
|
|
assert json.loads(state_path.read_text(encoding="ascii"))["bans"] == {}
|
|
assert len(commands) == 2
|
|
|
|
|
|
def test_updater_reuses_persisted_applied_digest_without_reloading(tmp_path):
|
|
instance, state_path, snippet_path = updater(tmp_path)
|
|
instance.update("ban", "192.0.2.4")
|
|
commands = []
|
|
persisted = updater_module.DenylistUpdater(
|
|
state_path, snippet_path, runner=lambda command: commands.append(command) or True,
|
|
clock=lambda: 1_700_000_001,
|
|
)
|
|
assert persisted.update("status")["bans"] == {"192.0.2.4": 1_700_086_400}
|
|
assert commands == []
|
|
|
|
|
|
def test_updater_rejects_corrupt_state_before_commands_or_snippet_changes(tmp_path):
|
|
commands = []
|
|
instance, state_path, snippet_path = updater(
|
|
tmp_path, runner=lambda command: commands.append(command) or True,
|
|
)
|
|
state_path.write_text('{"version":1,"bans":[]}', encoding="ascii")
|
|
before = snippet_path.read_bytes()
|
|
with pytest.raises(updater_module.UpdateError):
|
|
instance.update("status")
|
|
assert commands == []
|
|
assert snippet_path.read_bytes() == before
|
|
|
|
|
|
def test_updater_rejects_symlinked_snippet_when_supported(tmp_path):
|
|
commands = []
|
|
instance, state_path, snippet_path = updater(
|
|
tmp_path, runner=lambda command: commands.append(command) or True,
|
|
)
|
|
target = snippet_path.with_name("target.caddy")
|
|
target.write_text(updater_module.EMPTY_SNIPPET, encoding="ascii")
|
|
snippet_path.unlink()
|
|
try:
|
|
snippet_path.symlink_to(target)
|
|
except OSError:
|
|
pytest.skip("symlink creation is unavailable")
|
|
with pytest.raises(updater_module.UpdateError):
|
|
instance.update("ban", "192.0.2.5")
|
|
assert commands == []
|
|
assert not state_path.exists()
|
|
|
|
|
|
@pytest.mark.parametrize("value", ["", "1.2.3.4/32", "1.2.3.4;touch /tmp/x", "::%eth0", "0.0.0.0", "ff02::1"])
|
|
def test_updater_rejects_non_ip_or_unsafe_addresses_without_commands(tmp_path, value):
|
|
commands = []
|
|
instance, state_path, snippet_path = updater(
|
|
tmp_path, runner=lambda command: commands.append(command) or True,
|
|
)
|
|
before = snippet_path.read_bytes()
|
|
with pytest.raises(updater_module.UpdateError):
|
|
instance.update("ban", value)
|
|
assert commands == []
|
|
assert not state_path.exists()
|
|
assert snippet_path.read_bytes() == before
|
|
|
|
|
|
def test_updater_renders_deterministically_and_bounds_matcher_lines():
|
|
bans = {"2001:db8::2": 4, "192.0.2.10": 3, "2001:db8::1": 2, "192.0.2.2": 1}
|
|
rendered = updater_module.render_snippet(bans).decode("ascii")
|
|
matcher = next(line for line in rendered.splitlines() if line.startswith("@truf_admin_denied_"))
|
|
assert matcher.endswith("192.0.2.2 192.0.2.10 2001:db8::1 2001:db8::2")
|
|
assert max(map(len, rendered.splitlines())) < 4096
|
|
assert "global" not in rendered and "iptables" not in rendered
|
|
|
|
|
|
def test_updater_rolls_back_state_and_snippet_when_reload_fails(tmp_path):
|
|
instance, state_path, snippet_path = updater(tmp_path)
|
|
instance.update("status")
|
|
old_state = state_path.read_bytes()
|
|
old_snippet = snippet_path.read_bytes()
|
|
reloads = 0
|
|
|
|
def fail_first_reload(command):
|
|
nonlocal reloads
|
|
if "reload" in command:
|
|
reloads += 1
|
|
return reloads > 1
|
|
return True
|
|
|
|
instance.runner = fail_first_reload
|
|
with pytest.raises(updater_module.CommandFailure):
|
|
instance.update("ban", "198.51.100.9")
|
|
assert reloads == 2
|
|
assert state_path.read_bytes() == old_state
|
|
assert snippet_path.read_bytes() == old_snippet
|
|
|
|
|
|
def test_updater_rolls_back_without_reload_when_validation_fails(tmp_path):
|
|
instance, state_path, snippet_path = updater(tmp_path)
|
|
instance.update("status")
|
|
old_state = state_path.read_bytes()
|
|
old_snippet = snippet_path.read_bytes()
|
|
commands = []
|
|
|
|
def fail_validation(command):
|
|
commands.append(command)
|
|
return "validate" not in command
|
|
|
|
instance.runner = fail_validation
|
|
with pytest.raises(updater_module.CommandFailure):
|
|
instance.update("ban", "198.51.100.11")
|
|
assert len(commands) == 1
|
|
assert state_path.read_bytes() == old_state
|
|
assert snippet_path.read_bytes() == old_snippet
|
|
|
|
|
|
def test_fail2ban_filter_counts_only_redacted_supplied_bad_credentials():
|
|
parser = configparser.ConfigParser(interpolation=None)
|
|
parser.read(ROOT / "deploy" / "fail2ban" / "filter.d-truf-admin-auth.conf", encoding="ascii")
|
|
failregex = parser["Definition"]["failregex"]
|
|
host = r"(?P<host>[0-9A-Fa-f:.]+)"
|
|
pattern = re.compile(failregex.replace("<HOST>", host))
|
|
bad = '{"level":"info","ts":1700000000.1,"logger":"http.log.access.admin_auth_failures","msg":"handled request","status":401,"event":"admin_auth_failure","remote_ip":"203.0.113.8"}'
|
|
assert pattern.match(bad).group("host") == "203.0.113.8"
|
|
assert not pattern.match(bad.replace('"status":401', '"status":200'))
|
|
assert not pattern.match(bad.replace('"event":"admin_auth_failure",', ""))
|
|
assert not pattern.match(bad.replace('"remote_ip":"203.0.113.8"', '"remote_ip":"203.0.113.8","uri":"/secret"'))
|
|
challenge = '{"level":"info","ts":1700000000.1,"status":401,"remote_ip":"203.0.113.8"}'
|
|
assert not pattern.match(challenge)
|
|
|
|
|
|
def test_fail2ban_jail_is_persistent_bounded_and_admin_only():
|
|
jail = (ROOT / "deploy" / "fail2ban" / "jail.d-truf-admin-auth.local").read_text(encoding="ascii")
|
|
persistence = (ROOT / "deploy" / "fail2ban" / "fail2ban.d-truf-persistence.local").read_text(encoding="ascii")
|
|
action = (ROOT / "deploy" / "fail2ban" / "action.d-truf-caddy-admin-denylist.conf").read_text(encoding="ascii")
|
|
assert "maxretry = 2" in jail
|
|
assert "findtime = 10m" in jail
|
|
assert "bantime = 24h" in jail
|
|
assert "dbfile = /var/lib/fail2ban/fail2ban.sqlite3" in persistence
|
|
assert "actionban = /usr/local/sbin/truf-caddy-admin-denylist ban '<ip>'" in action
|
|
forbidden = ("iptables", "nft", "firewall-cmd", "ufw")
|
|
assert not any(name in (jail + action).lower() for name in forbidden)
|
|
|
|
|
|
def test_caddy_routes_and_failure_log_are_closed_and_redacted():
|
|
caddy = (ROOT / "deploy" / "edge" / "Caddyfile").read_text(encoding="ascii")
|
|
assert "admin unix//run/caddy-admin.sock" in caddy
|
|
assert "skip_install_trust" in caddy
|
|
assert "trusted_proxies" not in caddy
|
|
assert "import {$TRUF_EDGE_TLS_INCLUDE:/etc/caddy/tls/automatic.caddy}" in caddy
|
|
site = caddy.split("{$TRUF_EDGE_HOST} {", 1)[1]
|
|
assert site.index("import admin_security") < site.index("@worker path")
|
|
assert "@worker path /api/v1/worker/*" in caddy
|
|
assert "@admin_root path /{$TRUF_ADMIN_PREFIX}" in caddy
|
|
assert "@admin path /{$TRUF_ADMIN_PREFIX}/*" in caddy
|
|
assert "redir * /{$TRUF_ADMIN_PREFIX}/ 308" in caddy
|
|
assert "header_down -Strict-Transport-Security" in caddy
|
|
assert "uri strip_prefix /{$TRUF_ADMIN_PREFIX}" in caddy
|
|
assert "request_header -X-Truf-Admin-Edge" in caddy
|
|
assert "request_header -X-Truf-Admin-Operator" in caddy
|
|
assert "header_up X-Truf-Admin-Edge {$TRUF_ADMIN_EDGE_MARKER}" in caddy
|
|
assert "header_up X-Truf-Admin-Operator {http.auth.user.id}" in caddy
|
|
assert caddy.count("header_up -X-Truf-Admin-Operator") == 1
|
|
assert "uri path_regexp ^ /admin-internal" in caddy
|
|
assert "reverse_proxy 127.0.0.1:8766" in caddy
|
|
assert "127.0.0.1:8767" not in caddy
|
|
assert "import {$TRUF_ADMIN_DENYLIST_FILE:/etc/caddy/denylist/admin-denylist.caddy}" in caddy
|
|
assert "basic_auth bcrypt" in caddy
|
|
assert 'respond "" 404' in caddy
|
|
assert "request delete" in caddy and "resp_headers delete" in caddy
|
|
assert "log routine_access" in caddy and "output discard" in caddy
|
|
assert "log_name admin_auth_failures" in caddy
|
|
assert "header Authorization *" in caddy
|
|
assert "@admin_unauthorized" in caddy
|
|
assert caddy.count('WWW-Authenticate "Basic realm=\\"truf-admin\\""') == 2
|
|
for header in ("no-store", "same-origin", "Content-Security-Policy", "nosniff", "Strict-Transport-Security"):
|
|
assert header in caddy
|
|
assert "unsafe-inline" not in caddy
|
|
assert not re.search(r"\bpath\s+/dashboard(?:\s|$)", caddy)
|
|
assert not re.search(r"\bpath\s+/admin(?:\s|$)", caddy)
|
|
|
|
|
|
def test_edge_compose_only_opts_runtime_namespace_into_https_publication():
|
|
base = yaml.safe_load((ROOT / "compose.yaml").read_text(encoding="ascii"))
|
|
edge = yaml.safe_load((ROOT / "compose.edge.yaml").read_text(encoding="ascii"))
|
|
assert "ports" not in base["services"]["runtime"]
|
|
assert base["services"]["runtime"]["read_only"] is True
|
|
assert edge["services"]["runtime"]["ports"] == [{
|
|
"target": 443, "published": "443", "protocol": "tcp", "mode": "host",
|
|
}]
|
|
service = edge["services"]["edge"]
|
|
assert "TRUF_CADDY_ADMIN" not in service["environment"]
|
|
assert service["network_mode"] == "service:runtime"
|
|
assert "ports" not in service
|
|
assert service["read_only"] is True
|
|
assert service["cap_drop"] == ["ALL"]
|
|
assert service["cap_add"] == ["NET_BIND_SERVICE"]
|
|
assert service["build"]["target"] == "edge"
|
|
config = yaml.safe_load((ROOT / "app" / "config.linux.yaml").read_text(encoding="utf-8"))
|
|
assert config["supervisor"]["worker_api"]["enabled"] is False
|
|
assert config["supervisor"]["worker_api"]["admin"]["enabled"] is False
|
|
assert config["supervisor"]["worker_api"]["address"] == "127.0.0.1"
|
|
assert config["supervisor"]["dashboard"]["address"] == "127.0.0.1"
|
|
|
|
|
|
def test_edge_image_and_startup_require_pinned_caddy_hash_and_random_prefix():
|
|
dockerfile = (ROOT / "deploy" / "edge" / "Dockerfile").read_text(encoding="ascii")
|
|
entrypoint = (ROOT / "deploy" / "edge" / "entrypoint.sh").read_text(encoding="ascii")
|
|
assert re.search(r"^FROM caddy:2\.10\.2-alpine@sha256:[0-9a-f]{64} AS edge$", dockerfile, re.MULTILINE)
|
|
assert "${#prefix}" in entrypoint and "^[0-9a-f]{64}$" in entrypoint
|
|
assert "supported bcrypt hash" in entrypoint
|
|
assert "TRUF_ADMIN_EDGE_MARKER must encode 256 random bits" in entrypoint
|
|
assert "deploy/edge/automatic-tls.caddy /etc/caddy/tls/automatic.caddy" in dockerfile
|
|
assert "localhost requires an explicit static TLS include" in entrypoint
|
|
assert "TRUF_EDGE_TLS_INCLUDE must be an absolute Caddy TLS include" in entrypoint
|
|
assert 'exec caddy run --config "$caddyfile"' in entrypoint
|
|
assert "FROM edge AS edge-e2e" in dockerfile
|
|
assert "admin 127.0.0.1:2019" in dockerfile
|
|
|
|
|
|
def test_shared_host_edge_is_loopback_only_marker_gated_and_route_confined():
|
|
compose = yaml.safe_load((ROOT / "compose.shared-host.yaml").read_text(encoding="ascii"))
|
|
provision = compose["services"]["provision"]
|
|
runtime = compose["services"]["runtime"]
|
|
edge = compose["services"]["edge"]
|
|
assert provision == {"cpus": 0.9, "mem_limit": "720m"}
|
|
assert runtime == {
|
|
"network_mode": "host", "cpus": 0.9, "mem_limit": "720m",
|
|
}
|
|
assert edge["network_mode"] == "service:runtime"
|
|
assert "ports" not in edge and "cap_add" not in edge
|
|
assert edge["cap_drop"] == ["ALL"]
|
|
assert edge["environment"]["TRUF_EDGE_MODE"] == "shared-host-edge-v1"
|
|
assert "TRUF_SHARED_INGRESS_MARKER" in edge["environment"]
|
|
assert compose["volumes"]["data"] == {
|
|
"external": True, "name": "truf-remote-server-data",
|
|
}
|
|
|
|
caddy = (ROOT / "deploy" / "edge" / "Caddyfile.shared-host").read_text(encoding="ascii")
|
|
assert "http://:18766" in caddy
|
|
assert "\tbind 127.0.0.1" in caddy
|
|
assert "http://127.0.0.1:18766" not in caddy
|
|
assert "admin unix//run/caddy-admin.sock" in caddy
|
|
assert "trusted_proxies static 127.0.0.1/32 ::1/128" in caddy
|
|
assert "X-Truf-Shared-Ingress {$TRUF_SHARED_INGRESS_MARKER}" in caddy
|
|
assert "request_header -X-Truf-Shared-Ingress" in caddy
|
|
assert "log_append remote_ip {http.request.client_ip}" in caddy
|
|
assert "tls " not in caddy.lower()
|
|
|
|
snippet = (ROOT / "deploy" / "edge" / "host-caddy-shared.caddy").read_text(encoding="ascii")
|
|
assert snippet.count("reverse_proxy 127.0.0.1:18766") == 2
|
|
assert "@truf_worker path /api/v1/worker/*" in snippet
|
|
assert "@truf_admin path /{$TRUF_ADMIN_PREFIX}" in snippet
|
|
assert snippet.count(
|
|
"header_up X-Truf-Shared-Ingress {$TRUF_SHARED_INGRESS_MARKER}"
|
|
) == 2
|
|
assert "header_up -X-Truf-Shared-Ingress" not in snippet
|
|
assert "tls" not in snippet.lower()
|
|
assert "handle {" not in snippet
|
|
|
|
|
|
def test_shared_host_denylist_uses_forwarded_client_identity_and_private_reload(tmp_path):
|
|
instance, _, snippet = updater(tmp_path)
|
|
instance = updater_module.DenylistUpdater(
|
|
instance.state_path,
|
|
snippet,
|
|
project_directory=tmp_path / "project",
|
|
env_file=tmp_path / "edge.env",
|
|
profile=updater_module.SHARED_HOST_PROFILE,
|
|
runner=lambda command: True,
|
|
clock=lambda: 1_700_000_000,
|
|
)
|
|
instance.update("ban", "198.51.100.7")
|
|
assert "client_ip 198.51.100.7" in snippet.read_text(encoding="ascii")
|
|
assert any(str(value).endswith("compose.shared-host.yaml") for value in instance.validate_command)
|
|
assert "/etc/caddy/Caddyfile.shared-host" in instance.validate_command
|
|
assert "unix//run/caddy-admin.sock" in instance.reload_command
|
|
|
|
|
|
def test_edge_e2e_image_inputs_are_exact_and_host_orchestrator_stays_host_only():
|
|
dockerignore = (ROOT / ".dockerignore").read_text(encoding="ascii").splitlines()
|
|
allowed = {line[1:] for line in dockerignore if line.startswith("!")}
|
|
edge_e2e_inputs = {name for name in allowed if "edge_e2e" in name}
|
|
assert edge_e2e_inputs == {
|
|
"deploy/fail2ban/edge_e2e_docker_shim.py",
|
|
"tests/edge_e2e_backend.py",
|
|
"tests/edge_e2e_client.py",
|
|
}
|
|
assert "docker/verify_edge_e2e.py" not in allowed
|
|
|
|
dockerfile = (ROOT / "Dockerfile").read_text(encoding="ascii")
|
|
test_stage = dockerfile.split("FROM runtime-base AS test", 1)[1].split(
|
|
"FROM runtime-base AS runtime", 1,
|
|
)[0]
|
|
assert "COPY --chown=10001:10001 tests/ /opt/truf/tests/" in test_stage
|
|
assert "COPY --chown=10001:10001 docker/ /opt/truf/docker/" in test_stage
|
|
assert "edge_e2e != {'edge_e2e_backend.py', 'edge_e2e_client.py'}" in test_stage
|
|
assert "COPY app/" not in test_stage
|
|
|
|
|
|
def test_production_server_omits_trufflehog_but_worker_and_test_retain_it():
|
|
dockerfile = (ROOT / "Dockerfile").read_text(encoding="ascii")
|
|
server_native = dockerfile.split("FROM python-base AS native-dependencies", 1)[1].split(
|
|
"FROM native-dependencies AS dependencies", 1,
|
|
)[0]
|
|
test_stage = dockerfile.split("FROM runtime-base AS test", 1)[1].split(
|
|
"FROM runtime-base AS runtime", 1,
|
|
)[0]
|
|
worker_native = dockerfile.split("FROM python-base AS worker-native-dependencies", 1)[1].split(
|
|
"FROM worker-native-dependencies AS worker", 1,
|
|
)[0]
|
|
assert "trufflehog" not in server_native.lower()
|
|
assert "/usr/local/bin/trufflehog" in worker_native
|
|
assert "/usr/local/bin/trufflehog" in test_stage
|
|
|
|
|
|
def test_edge_e2e_orchestrator_is_private_labelled_and_uses_production_updater():
|
|
orchestrator = (ROOT / "docker" / "verify_edge_e2e.py").read_text(encoding="ascii")
|
|
backend = (ROOT / "tests" / "edge_e2e_backend.py").read_text(encoding="ascii")
|
|
assert "TEST_IMAGE = 'truf-edge-e2e-runtime:test'" in orchestrator
|
|
assert "EDGE_IMAGE = 'truf-edge-e2e:test'" in orchestrator
|
|
assert "FAIL2BAN_IMAGE = 'truf-fail2ban-edge-e2e:test'" in orchestrator
|
|
assert "truf-local:edge" not in orchestrator
|
|
assert "'network', 'create', '--driver', 'bridge', '--internal'" in orchestrator
|
|
assert "'--network', 'container:' + self.names['backend']" in orchestrator
|
|
assert "--publish" not in orchestrator and "--privileged" not in orchestrator
|
|
assert "docker compose" not in orchestrator.lower()
|
|
assert "DenylistUpdater(" not in orchestrator
|
|
assert "update_denylist(" not in orchestrator
|
|
assert "'/usr/bin/fail2ban-client'" in orchestrator
|
|
assert "'--pid', 'container:' + self.names['edge']" in orchestrator
|
|
assert "TRUF_CADDY_ADMIN" not in orchestrator
|
|
assert "'--cap-add', 'KILL'" not in orchestrator
|
|
assert "'--user', '10001:10001', '--read-only', '--cap-drop', 'ALL'" in orchestrator
|
|
assert "/var/run/docker.sock" not in orchestrator
|
|
assert "expected={direct_ip}" in orchestrator
|
|
assert "['unbanip', direct_ip]" in orchestrator
|
|
assert "['container', 'kill', '--signal', 'SIGKILL', fail2ban['id']]" in orchestrator
|
|
assert "['gitlab', 'dockerhub', 'huggingface']" in backend
|
|
assert "db.enqueue_targets('gitlab', 'gitlab'" in backend
|
|
assert "baseline['operation_id']" in orchestrator
|
|
|
|
|
|
def test_production_runbook_requires_fixed_host_agent_and_runtime_paths():
|
|
readme = (ROOT / "deploy" / "edge" / "README.md").read_text(encoding="ascii")
|
|
for required in (
|
|
"truf_host_agent_install.py install",
|
|
"truf_host_agent_install.py validate",
|
|
"/run/truf/host-agent.sock",
|
|
"/etc/truf/runtime/config.yaml",
|
|
"/etc/truf/runtime",
|
|
"/etc/truf/worker-packages",
|
|
"/data/worker-packages",
|
|
"root:root mode 0644",
|
|
"/data/managed-files",
|
|
"-f compose.yaml -f compose.edge.yaml",
|
|
):
|
|
assert required in readme
|
|
assert readme.index("Create `/etc/truf-edge/edge.env`") < readme.index(
|
|
"truf_host_agent_install.py install"
|
|
)
|
|
assert "build runtime edge" in readme
|
|
|
|
|
|
def test_real_caddy_e2e_crawls_all_admin_routes_and_detail():
|
|
client = (ROOT / "tests" / "edge_e2e_client.py").read_text(encoding="ascii")
|
|
for route in (
|
|
"'overview'", "'search'", "'supervisor'", "'logs'", "'config'",
|
|
"'secrets'", "'files'", "'operations'", "'audit'",
|
|
):
|
|
assert route in client
|
|
assert "f'operations/{same_site_operation_id}'" in client
|
|
|
|
|
|
def test_fail2ban_edge_e2e_image_is_pinned_minimal_and_uses_production_gate_files():
|
|
dockerfile = (ROOT / "deploy" / "fail2ban" / "Dockerfile.edge-e2e").read_text(
|
|
encoding="ascii",
|
|
)
|
|
assert re.search(
|
|
r"^FROM caddy:2\.10\.2-alpine@sha256:[0-9a-f]{64} AS caddy-edge-e2e$",
|
|
dockerfile,
|
|
re.MULTILINE,
|
|
)
|
|
assert re.search(
|
|
r"^FROM debian:bookworm-slim@sha256:[0-9a-f]{64} AS fail2ban-edge-e2e$",
|
|
dockerfile,
|
|
re.MULTILINE,
|
|
)
|
|
assert "fail2ban=1.0.2-2" in dockerfile
|
|
for path in (
|
|
"filter.d-truf-admin-auth.conf",
|
|
"jail.d-truf-admin-auth.local",
|
|
"action.d-truf-caddy-admin-denylist.conf",
|
|
"fail2ban.d-truf-persistence.local",
|
|
"truf_caddy_admin_denylist.py",
|
|
):
|
|
assert path in dockerfile
|
|
assert "apt-get install -y --no-install-recommends" in dockerfile
|
|
assert "docker-ce" not in dockerfile and "docker.io" not in dockerfile
|
|
assert "admin 127.0.0.1:2019" in dockerfile
|
|
|
|
runtime = (ROOT / "docker" / "Dockerfile.edge-e2e").read_text(encoding="ascii")
|
|
assert runtime.startswith("FROM truf-worker-test:test AS edge-e2e-runtime\n")
|
|
assert "tests/edge_e2e_backend.py tests/edge_e2e_client.py" in runtime
|
|
|
|
|
|
def test_fail2ban_edge_e2e_reload_shim_accepts_only_production_updater_commands(tmp_path):
|
|
path = ROOT / "deploy" / "fail2ban" / "edge_e2e_docker_shim.py"
|
|
spec = importlib.util.spec_from_file_location("truf_edge_e2e_docker_shim", path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
|
|
assert module.classify_command(module.VALIDATE_COMMAND) == "validate"
|
|
assert module.classify_command(module.RELOAD_COMMAND) == "reload"
|
|
for command in (
|
|
module.VALIDATE_COMMAND[:-1],
|
|
module.RELOAD_COMMAND + ("other",),
|
|
tuple("/var/run/docker.sock" if value == "edge" else value for value in module.RELOAD_COMMAND),
|
|
):
|
|
with pytest.raises(ValueError):
|
|
module.classify_command(command)
|
|
|
|
env_file = tmp_path / "edge.env"
|
|
env_file.write_text(
|
|
"TRUF_EDGE_HOST=localhost\n"
|
|
"TRUF_EDGE_TLS_INCLUDE=/etc/caddy/tls/static-tls.caddy\n"
|
|
f"TRUF_ADMIN_PREFIX={'a' * 64}\n"
|
|
"TRUF_ADMIN_USER=edge-e2e-admin\n"
|
|
f"TRUF_ADMIN_PASSWORD_HASH=$2b$12${'A' * 53}\n"
|
|
f"TRUF_ADMIN_EDGE_MARKER={'b' * 64}\n",
|
|
encoding="ascii",
|
|
)
|
|
values = module.load_environment(env_file)
|
|
assert set(module.REQUIRED_ENV).issubset(values)
|
|
assert values["PATH"] == "/usr/bin:/bin"
|
|
|
|
module.VALIDATION_ERROR_PATH = tmp_path / "validation.error"
|
|
secret = values["TRUF_ADMIN_PASSWORD_HASH"]
|
|
module.record_validation_error(
|
|
(secret + " " + "c" * 64 + "\n" + "x" * 8192).encode("ascii"), values,
|
|
)
|
|
evidence = module.VALIDATION_ERROR_PATH.read_bytes()
|
|
assert len(evidence) <= 4096
|
|
assert secret.encode("ascii") not in evidence
|
|
assert ("c" * 64).encode("ascii") not in evidence
|
|
|
|
|
|
def test_docker_e2e_verifiers_snapshot_foreign_state_and_revalidate_cleanup():
|
|
paths = (
|
|
ROOT / "docker" / "verify_packaged_workers.py",
|
|
ROOT / "docker" / "verify_edge_e2e.py",
|
|
)
|
|
for path in paths:
|
|
source = path.read_text(encoding="ascii")
|
|
assert "MAX_DOCKER_RESOURCES" in source
|
|
assert "CONTAINER_METADATA_FORMAT" in source
|
|
assert "VOLUME_METADATA_FORMAT" in source
|
|
assert "snapshot_foreign()" in source
|
|
assert "metadata_snapshot(exclude_owned=True)" in source
|
|
assert "foreign_docker_state_changed" in source
|
|
assert "def guarded_stop(self):" in source
|
|
assert "inspect_owned('containers', name, expected)" in source
|
|
assert "inspect_owned('volumes', name, expected)" in source
|
|
assert "inspect_owned('networks', name, expected)" in source
|
|
assert "def isolated_test_subnet(" in source
|
|
assert "'--subnet', isolated_test_subnet(" in source
|
|
assert "return f'198." in source
|
|
assert "['container', 'rm', '--force'" not in source
|
|
assert "system prune" not in source.lower()
|
|
|
|
|
|
def test_e2e_failure_evidence_is_sanitized_metadata_only():
|
|
packaged = (ROOT / "docker" / "verify_packaged_workers.py").read_text(encoding="ascii")
|
|
edge = (ROOT / "docker" / "verify_edge_e2e.py").read_text(encoding="ascii")
|
|
for source in (packaged, edge):
|
|
assert "'stage': label, 'class': failure_class, 'exit_code': exit_code" in source
|
|
assert "'artifacts': os.fspath(verifier.run_root) if verifier is not None else None" not in source
|
|
assert "'owned': verifier" not in source
|
|
assert "clear_run_artifacts()" in source
|
|
assert "secret_present_in_evidence" in source
|
|
assert "'stderr': stderr.decode" not in edge
|
|
assert "'stdout': stdout.decode" not in edge
|
|
assert "client-" + "failure.json" not in edge
|
|
assert "write_bytes(phase_root / 'client.log'" not in packaged
|
|
assert "write_bytes(phase_root / 'server.log'" not in packaged
|
|
assert "write_json(phase_root / 'completed.json'" not in packaged
|
|
assert "retain_safe_evidence('summary.json', summary)" in packaged
|
|
|
|
|
|
def test_edge_failure_stop_revalidates_then_targets_only_captured_id():
|
|
path = ROOT / "docker" / "verify_edge_e2e.py"
|
|
spec = importlib.util.spec_from_file_location("truf_verify_edge_e2e_pure", path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
run_id = "a" * 16
|
|
name = f"truf-edge-e2e-{run_id}-backend"
|
|
identifier = "b" * 64
|
|
|
|
class Runner:
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def docker(self, label, arguments, **kwargs):
|
|
self.calls.append((label, arguments))
|
|
if label == "inspect_owned_container":
|
|
return 0, json.dumps({
|
|
"id": identifier, "name": "/" + name, "run": run_id,
|
|
"kind": "backend", "running": True, "paused": False,
|
|
"restarting": False,
|
|
}).encode("ascii"), b""
|
|
assert label == "guarded_stop_owned_container"
|
|
return 0, b"", b""
|
|
|
|
runner = Runner()
|
|
resources = module.Resources(runner, run_id)
|
|
resources.created["containers"][name] = {"id": identifier, "kind": "backend"}
|
|
assert resources.guarded_stop() == 1
|
|
assert [label for label, _ in runner.calls] == [
|
|
"inspect_owned_container", "inspect_owned_container",
|
|
"guarded_stop_owned_container",
|
|
]
|
|
assert runner.calls[-1][1] == ["container", "stop", "--time", "30", identifier]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("filename", "method"),
|
|
[
|
|
("verify_packaged_workers.py", "retain_safe_evidence"),
|
|
("verify_edge_e2e.py", "retain_failure_evidence"),
|
|
],
|
|
)
|
|
def test_failure_evidence_removes_raw_artifacts_before_safe_write(tmp_path, filename, method):
|
|
path = ROOT / "docker" / filename
|
|
spec = importlib.util.spec_from_file_location("truf_" + path.stem + "_pure", path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
verifier = object.__new__(module.Verifier)
|
|
verifier.root = tmp_path
|
|
build = tmp_path / "build"
|
|
build.mkdir()
|
|
prefix = "packaged-worker-e2e-" if "packaged" in filename else "edge-e2e-"
|
|
verifier.run_root = build / (prefix + "a" * 16)
|
|
verifier.run_root.mkdir()
|
|
raw = verifier.run_root / "fixture"
|
|
raw.mkdir()
|
|
client_log = raw / "client.log"
|
|
client_log.write_text("SECRET-SENTINEL", encoding="ascii")
|
|
client_log.chmod(stat.S_IRUSR)
|
|
raw.chmod(stat.S_IRUSR | stat.S_IXUSR)
|
|
if "packaged" in filename:
|
|
verifier.tokens = {"target": "SECRET-SENTINEL"}
|
|
verifier.device_tokens = {}
|
|
getattr(verifier, method)("failure.json", {
|
|
"stage": "test_failure", "class": "Failure", "exit_code": 7,
|
|
})
|
|
else:
|
|
verifier.admin_password = "SECRET-SENTINEL"
|
|
verifier.edge_marker = "edge-marker"
|
|
verifier.prefix_secret = "prefix-secret"
|
|
verifier.tokens = {}
|
|
getattr(verifier, method)({
|
|
"stage": "test_failure", "class": "Failure", "exit_code": 7,
|
|
})
|
|
assert [item.name for item in verifier.run_root.iterdir()] == ["failure.json"]
|
|
failure = json.loads((verifier.run_root / "failure.json").read_text(encoding="ascii"))
|
|
assert failure == {"stage": "test_failure", "class": "Failure", "exit_code": 7}
|
|
|
|
|
|
def test_packaged_restart_proof_uses_stable_durable_state_not_log_timing():
|
|
source = (ROOT / "docker" / "verify_packaged_workers.py").read_text(encoding="ascii")
|
|
assert "def assert_restart_stable(" in source
|
|
assert "_restart_rescanned_or_rewrote_bundle" in source
|
|
assert "checks >= 2" in source
|
|
assert "time.monotonic() - started >= 2.5" in source
|
|
assert "wait_windows_restart_attempts" not in source
|
|
assert "wait_linux_restart_attempts" not in source
|
|
|
|
|
|
def test_worker_packaging_boundary_allows_shared_db_free_modules_only():
|
|
readme = (ROOT / "docker" / "build-dependencies" / "README.md").read_text(
|
|
encoding="ascii",
|
|
)
|
|
assert "required shared DB-free modules" in readme
|
|
assert "server runtime/control authority" in readme
|
|
assert "provider and detailed" in readme and "keycheck authority" in readme
|
|
assert "server credentials, and database credentials are absent" in readme
|
|
assert "grants no provider or detailed keycheck authority" in readme
|
|
assert "PostgreSQL tools, server/test code" not in readme
|
|
|
|
|
|
def test_production_updater_cli_defaults_remain_unchanged():
|
|
args = updater_module.parse_args(["status"])
|
|
assert args.state_path == "/var/lib/truf-edge/admin-denylist.json"
|
|
assert args.snippet_path == "/etc/truf-edge/denylist/admin-denylist.caddy"
|
|
assert args.project_directory == "/opt/truf"
|
|
assert args.env_file == "/etc/truf-edge/edge.env"
|