Files
recipe-maintainer/sandbox/tests/test_env_hiding.py
autonomic-bot f283a371bb recipe-maintainer: public snapshot (secrets + deployment plans removed, single commit)
Sanitized single-commit public mirror of recipe-maintainer.
- Removed test-ssh/.testenv (live creds); added test-ssh/.testenv.example placeholders.
- Removed plans/ and planned-updates/ (deployment-planning docs) so no client/
  deployment domains appear in the public repo.
- All other secret stores were already gitignored.
- docs.coopcloud.tech retained as a submodule (public upstream).
2026-06-16 20:18:24 +00:00

264 lines
9.1 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Test that sensitive files (.env, .envrc, secret.json, secrets.json) and
sensitive directories (.ssh) are hidden from the container user via Docker
compose overrides (/dev/null mounts for files, tmpfs for directories).
All test files are created inside tests/fixtures/env-hiding/ to avoid touching
any real sensitive files in the repository root.
Uses the real docker-compose.yml plus a generated override file.
Usage: python tests/test_env_hiding.py
Requires: docker compose, the "sandbox" image already built.
"""
import os
import shutil
import stat
import subprocess
import sys
import tempfile
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(SCRIPT_DIR)
FIXTURE_DIR = os.path.join(SCRIPT_DIR, "fixtures", "env-hiding")
COMPOSE_FILE = os.path.join(REPO_ROOT, "docker-compose.yml")
SERVICE = "claude"
CONTAINER_FIXTURE = "/workspace/tests/fixtures/env-hiding"
# Import helpers from claude.py
sys.path.insert(0, REPO_ROOT)
from claude import find_sensitive_files, find_sensitive_dirs, generate_override
# Fixtures: (relative path, content)
FIXTURES = [
(".env", "SECRET=hunter2"),
(".envrc", "ENVRC_SECRET=s3cret"),
("subdir/.env", "NESTED_SECRET=deep"),
("subdir/.envrc", "NESTED_ENVRC=deeper"),
("secret.json", '{"api_key":"top-secret"}'),
("secrets.json", '{"tokens":["abc123"]}'),
("subdir/secret.json", '{"db_pass":"hidden"}'),
("subdir/secrets.json", '{"creds":["xyz789"]}'),
]
passed = 0
failed = 0
def assert_eq(desc, expected, actual):
global passed, failed
if expected == actual:
print(f" PASS: {desc}")
passed += 1
else:
print(f" FAIL: {desc} (expected={expected!r}, got={actual!r})")
failed += 1
def assert_contains(desc, needle, haystack):
global passed, failed
if needle.lower() in haystack.lower():
print(f" PASS: {desc}")
passed += 1
else:
print(f" FAIL: {desc} (expected to contain {needle!r}, got {haystack!r})")
failed += 1
def dc_run(override_file, *args):
"""Run a command in the container with TTY disabled."""
print(" [dc_run] Starting container...")
result = subprocess.run(
[
"docker", "compose",
"-f", COMPOSE_FILE,
"-f", override_file,
"run", "-T", "--rm", "--no-deps",
SERVICE, *args,
],
capture_output=True, text=True,
)
output = result.stdout + result.stderr
if result.returncode != 0:
print(f" [dc_run] Container exited with code {result.returncode}")
for line in output.splitlines():
print(f" {line}")
else:
print(" [dc_run] Container exited successfully.")
return output
# .ssh fixture lives at the repo root so the dynamic tmpfs on /workspace/.ssh covers it
SSH_FIXTURE_DIR = os.path.join(REPO_ROOT, ".ssh")
SSH_FILES = {
"id_rsa": "-----BEGIN FAKE KEY-----\nsuper-secret\n-----END FAKE KEY-----",
"config": "Host *\n StrictHostKeyChecking no",
}
def create_fixtures():
"""Create test fixture files and return a dict of path -> (content, octal_perm)."""
os.makedirs(os.path.join(FIXTURE_DIR, "subdir"), exist_ok=True)
originals = {}
for rel_path, content in FIXTURES:
full = os.path.join(FIXTURE_DIR, rel_path)
with open(full, "w") as f:
f.write(content)
os.chmod(full, 0o644)
originals[rel_path] = (content, oct(os.stat(full).st_mode & 0o777))
# Create .ssh fixture directory
os.makedirs(SSH_FIXTURE_DIR, exist_ok=True)
for name, content in SSH_FILES.items():
full = os.path.join(SSH_FIXTURE_DIR, name)
with open(full, "w") as f:
f.write(content)
os.chmod(full, 0o600)
return originals
def cleanup():
shutil.rmtree(FIXTURE_DIR, ignore_errors=True)
shutil.rmtree(SSH_FIXTURE_DIR, ignore_errors=True)
def main():
print("=== Test: sensitive file hiding in Docker container ===\n")
# --- Setup ---
print(f"Setting up test fixtures in {FIXTURE_DIR} ...")
originals = create_fixtures()
print("Test fixtures created with permission 644.\n")
# Generate override for sensitive files and directories
sensitive = find_sensitive_files(REPO_ROOT)
sensitive_dirs = find_sensitive_dirs(REPO_ROOT)
# Append SSH fixture dir explicitly (it won't match SENSITIVE_DIRS paths)
if SSH_FIXTURE_DIR not in sensitive_dirs:
sensitive_dirs.append(SSH_FIXTURE_DIR)
override_file = generate_override(REPO_ROOT, sensitive, sensitive_dirs)
try:
with open(override_file) as f:
print("Override file contents:")
for line in f:
print(f" {line}", end="")
print()
# --- Test 1: sensitive files appear empty in container ---
print("--- Test 1: sensitive files appear empty to container user (mounted from /dev/null) ---")
# Build a shell command that cats each fixture and prints markers
cat_cmds = []
for rel_path, _ in FIXTURES:
label = rel_path.replace("/", " ")
container_path = f"{CONTAINER_FIXTURE}/{rel_path}"
cat_cmds.append(f"echo '--- {label} ---'; cat {container_path} 2>&1")
cat_cmds.append("echo '--- END ---'")
shell_script = "; ".join(cat_cmds)
output = dc_run(override_file, "bash", "-c", shell_script)
lines = output.splitlines()
# Parse output between markers — content between each pair should be empty
for i, (rel_path, _) in enumerate(FIXTURES):
label = rel_path.replace("/", " ")
start_marker = f"--- {label} ---"
if i + 1 < len(FIXTURES):
end_marker = f"--- {FIXTURES[i + 1][0].replace('/', ' ')} ---"
else:
end_marker = "--- END ---"
# Extract lines between markers
capturing = False
content_lines = []
for line in lines:
if end_marker in line:
capturing = False
if capturing:
content_lines.append(line)
if start_marker in line:
capturing = True
actual = "\n".join(content_lines)
assert_eq(f"{rel_path} is empty", "", actual)
print()
# --- Test 2: host permissions unchanged ---
print("--- Test 2: Host permissions unchanged after container stops ---")
for rel_path, (_, orig_perm) in originals.items():
full = os.path.join(FIXTURE_DIR, rel_path)
current_perm = oct(os.stat(full).st_mode & 0o777)
assert_eq(f"{rel_path} permissions unchanged", orig_perm, current_perm)
print()
# --- Test 3: host file contents intact ---
print("--- Test 3: File contents are intact on host ---")
for rel_path, (orig_content, _) in originals.items():
full = os.path.join(FIXTURE_DIR, rel_path)
with open(full) as f:
actual = f.read().strip()
assert_eq(f"{rel_path} content intact", orig_content, actual)
print()
# --- Test 4: non-sensitive files remain accessible ---
print("--- Test 4: Non-sensitive files remain accessible in container ---")
output = dc_run(
override_file, "bash", "-c",
"if [ -r /workspace/entrypoint.sh ]; then echo readable; else echo not readable; fi",
)
assert_contains("entrypoint.sh is readable", "readable", output)
print()
# --- Test 5: .ssh directory is hidden via dynamic tmpfs overlay ---
print("--- Test 5: .ssh directory is empty in container (tmpfs overlay) ---")
container_ssh = "/workspace/.ssh"
output = dc_run(
override_file, "bash", "-c",
f"echo FILES=$(ls -A {container_ssh} 2>&1); cat {container_ssh}/id_rsa 2>&1 || true",
)
assert_contains(".ssh directory listing is empty", "FILES=", output)
# ls -A of an empty tmpfs returns nothing after "FILES="
for line in output.splitlines():
if line.startswith("FILES="):
listing = line[len("FILES="):]
assert_eq(".ssh contains no files", "", listing)
break
assert_contains(".ssh/id_rsa not accessible", "no such file", output)
print()
# --- Test 5b: .ssh host contents intact ---
print("--- Test 5b: .ssh host contents intact after container stops ---")
for name, expected_content in SSH_FILES.items():
full = os.path.join(SSH_FIXTURE_DIR, name)
with open(full) as f:
actual = f.read().strip()
assert_eq(f".ssh/{name} content intact on host", expected_content.strip(), actual)
print()
finally:
os.unlink(override_file)
cleanup()
# --- Summary ---
total = passed + failed
print("===============================")
print(f"Results: {passed}/{total} passed, {failed} failed")
print("===============================")
if failed > 0:
sys.exit(1)
if __name__ == "__main__":
main()