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).
This commit is contained in:
263
sandbox/tests/test_env_hiding.py
Executable file
263
sandbox/tests/test_env_hiding.py
Executable file
@ -0,0 +1,263 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user