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).
264 lines
9.1 KiB
Python
Executable File
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()
|