Files
mvanhorn--last30days-skill/tests/test_check_config_env_safety.py
T

407 lines
13 KiB
Python

"""Security tests for hooks/scripts/check-config.sh env parsing.
Covers the SessionStart hook's .env loader:
- printf -v key injection (array-subscript command substitution)
- LAST30DAYS_TRUST_PROJECT_CONFIG gate matching lib/env.py
- Legitimate identifier keys still parse
The PoC key ``x[$(touch RCE-PROOF.txt)]=1`` executes under bash 4+/5 via
printf -v assignment semantics; bash 3.2 rejects it as an invalid identifier
and (with ``set -e``) aborts the hook. Either way, after the fix the hook must
exit 0 and must never create the proof file.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
from pathlib import Path
import pytest
HOOK = Path(__file__).resolve().parents[1] / "hooks" / "scripts" / "check-config.sh"
POC_LINE = "x[$(touch RCE-PROOF.txt)]=1\n"
def _bash_binaries() -> list[str]:
"""Prefer modern bash (4+) when present so the RCE path is actually exercised."""
seen: list[str] = []
for candidate in (
"/opt/homebrew/bin/bash",
"/usr/local/bin/bash",
shutil.which("bash"),
):
if not candidate:
continue
path = str(Path(candidate).resolve())
if path not in seen and Path(path).is_file():
seen.append(path)
return seen
def _bash_major(bash_path: str) -> int:
result = subprocess.run(
[bash_path, "-c", 'echo "${BASH_VERSINFO[0]}"'],
capture_output=True,
text=True,
timeout=10,
check=False,
)
try:
return int((result.stdout or "").strip() or "0")
except ValueError:
return 0
def _mode_bits_assertable() -> bool:
"""Match check_perms: Windows/MSYS synthesized modes are not meaningful."""
if os.name == "nt":
return False
uname = ""
try:
uname = os.uname().sysname # type: ignore[attr-defined]
except AttributeError:
return True
return not uname.startswith(("MINGW", "MSYS", "CYGWIN"))
def _assert_mode(path: Path, expected: str) -> None:
if _mode_bits_assertable():
assert oct(path.stat().st_mode)[-3:] == expected
def _isolated_path(tmp_path: Path) -> str:
"""PATH with a stub ``security`` so macOS Keychain presence cannot leak into assertions."""
bin_dir = tmp_path / "hook-bin"
bin_dir.mkdir(exist_ok=True)
security = bin_dir / "security"
if not security.exists():
security.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8")
security.chmod(0o755)
return f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}"
def _run_hook(
bash_path: str,
cwd: Path,
tmp_path: Path,
env_overrides: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
for k in (
"LAST30DAYS_MEMORY_DIR",
"SETUP_COMPLETE",
"LAST30DAYS_CONFIG_DIR",
"LAST30DAYS_TRUST_PROJECT_CONFIG",
"OPENAI_API_KEY",
"SCRAPECREATORS_API_KEY",
"AUTH_TOKEN",
"CT0",
"XAI_API_KEY",
"BSKY_HANDLE",
"EXA_API_KEY",
):
env.pop(k, None)
env["PATH"] = _isolated_path(tmp_path)
if env_overrides:
env.update(env_overrides)
return subprocess.run(
[bash_path, str(HOOK)],
capture_output=True,
text=True,
env=env,
cwd=str(cwd),
timeout=30,
check=False,
)
@pytest.fixture(params=_bash_binaries())
def bash_path(request: pytest.FixtureRequest) -> str:
return request.param
@pytest.mark.skipif(not _bash_binaries(), reason="bash not on PATH")
def test_malicious_project_env_key_does_not_execute(bash_path: str, tmp_path: Path):
"""Reporter PoC: crafted key must not run, even under bash 4+/5."""
project = tmp_path / "repo"
env_file = project / ".claude" / "last30days.env"
env_file.parent.mkdir(parents=True)
env_file.write_text(f"SETUP_COMPLETE=1\n{POC_LINE}", encoding="utf-8")
proof = project / "RCE-PROOF.txt"
result = _run_hook(
bash_path,
project,
tmp_path,
{
"LAST30DAYS_CONFIG_DIR": str(tmp_path / "empty-config"),
"LAST30DAYS_MEMORY_DIR": str(tmp_path / "memory"),
},
)
assert not proof.exists(), f"RCE proof file was created under {bash_path}"
assert result.returncode == 0, (
f"hook aborted under {bash_path}: stderr={result.stderr!r} stdout={result.stdout!r}"
)
@pytest.mark.skipif(not _bash_binaries(), reason="bash not on PATH")
def test_malicious_key_blocked_even_when_project_trusted(bash_path: str, tmp_path: Path):
"""Identifier gate must hold even after an explicit trust opt-in."""
project = tmp_path / "repo"
env_file = project / ".claude" / "last30days.env"
env_file.parent.mkdir(parents=True)
env_file.write_text(f"SETUP_COMPLETE=1\n{POC_LINE}", encoding="utf-8")
proof = project / "RCE-PROOF.txt"
result = _run_hook(
bash_path,
project,
tmp_path,
{
"LAST30DAYS_TRUST_PROJECT_CONFIG": "1",
"LAST30DAYS_CONFIG_DIR": str(tmp_path / "empty-config"),
"LAST30DAYS_MEMORY_DIR": str(tmp_path / "memory"),
},
)
assert not proof.exists(), f"RCE proof file was created under {bash_path} (trusted path)"
assert result.returncode == 0, (
f"hook aborted under {bash_path}: stderr={result.stderr!r} stdout={result.stdout!r}"
)
assert "Ready" in result.stdout
@pytest.mark.skipif(not _bash_binaries(), reason="bash not on PATH")
def test_untrusted_project_env_is_ignored(bash_path: str, tmp_path: Path):
"""Without LAST30DAYS_TRUST_PROJECT_CONFIG, project file is not read or chmod'd."""
project = tmp_path / "repo"
env_file = project / ".claude" / "last30days.env"
env_file.parent.mkdir(parents=True)
env_file.write_text(
"SETUP_COMPLETE=true\nSCRAPECREATORS_API_KEY=scrape-test-key-untrusted\n",
encoding="utf-8",
)
env_file.chmod(0o644)
result = _run_hook(
bash_path,
project,
tmp_path,
{
"LAST30DAYS_CONFIG_DIR": str(tmp_path / "empty-config"),
"LAST30DAYS_MEMORY_DIR": str(tmp_path / "memory"),
},
)
assert result.returncode == 0, result.stderr
# check_perms only runs on the chosen config file; untrusted project stays 644.
_assert_mode(env_file, "644")
# Project ScrapeCreators key must not suppress the tip when the configured
# banner path runs (isolated PATH stubs Keychain).
if "sources active" in result.stdout:
assert "Tip: Add ScrapeCreators" in result.stdout
else:
assert "Ready to use" in result.stdout
@pytest.mark.skipif(not _bash_binaries(), reason="bash not on PATH")
def test_trusted_project_env_loads_normal_keys(bash_path: str, tmp_path: Path):
project = tmp_path / "repo"
env_file = project / ".claude" / "last30days.env"
env_file.parent.mkdir(parents=True)
env_file.write_text(
"SETUP_COMPLETE=true\n"
"SCRAPECREATORS_API_KEY=scrape-test-key\n",
encoding="utf-8",
)
env_file.chmod(0o644)
result = _run_hook(
bash_path,
project,
tmp_path,
{
"LAST30DAYS_TRUST_PROJECT_CONFIG": "1",
"LAST30DAYS_CONFIG_DIR": str(tmp_path / "empty-config"),
"LAST30DAYS_MEMORY_DIR": str(tmp_path / "memory"),
},
)
assert result.returncode == 0, result.stderr
assert re.search(r"Ready — \d+ sources active", result.stdout)
assert "Tip: Add ScrapeCreators" not in result.stdout
_assert_mode(env_file, "600")
@pytest.mark.skipif(not _bash_binaries(), reason="bash not on PATH")
def test_global_trust_signal_unlocks_project_env(bash_path: str, tmp_path: Path):
"""Trust from ~/.config (via LAST30DAYS_CONFIG_DIR) unlocks project overlay."""
config_dir = tmp_path / "config"
config_dir.mkdir()
(config_dir / ".env").write_text(
"LAST30DAYS_TRUST_PROJECT_CONFIG=1\nSETUP_COMPLETE=true\n",
encoding="utf-8",
)
project = tmp_path / "repo"
env_file = project / ".claude" / "last30days.env"
env_file.parent.mkdir(parents=True)
env_file.write_text("SCRAPECREATORS_API_KEY=from-project\n", encoding="utf-8")
env_file.chmod(0o644)
result = _run_hook(
bash_path,
project,
tmp_path,
{
"LAST30DAYS_CONFIG_DIR": str(config_dir),
"LAST30DAYS_MEMORY_DIR": str(tmp_path / "memory"),
},
)
assert result.returncode == 0, result.stderr
assert re.search(r"Ready — \d+ sources active", result.stdout)
assert "Tip: Add ScrapeCreators" not in result.stdout
_assert_mode(env_file, "600")
@pytest.mark.skipif(not _bash_binaries(), reason="bash not on PATH")
def test_process_deny_overrides_global_trust(bash_path: str, tmp_path: Path):
config_dir = tmp_path / "config"
config_dir.mkdir()
(config_dir / ".env").write_text(
"LAST30DAYS_TRUST_PROJECT_CONFIG=1\nSETUP_COMPLETE=true\n",
encoding="utf-8",
)
project = tmp_path / "repo"
env_file = project / ".claude" / "last30days.env"
env_file.parent.mkdir(parents=True)
env_file.write_text("SCRAPECREATORS_API_KEY=from-project\n", encoding="utf-8")
env_file.chmod(0o644)
result = _run_hook(
bash_path,
project,
tmp_path,
{
"LAST30DAYS_TRUST_PROJECT_CONFIG": "0",
"LAST30DAYS_CONFIG_DIR": str(config_dir),
"LAST30DAYS_MEMORY_DIR": str(tmp_path / "memory"),
},
)
assert result.returncode == 0, result.stderr
assert re.search(r"Ready — \d+ sources active", result.stdout)
assert "Tip: Add ScrapeCreators" in result.stdout
_assert_mode(env_file, "644")
@pytest.mark.skipif(not _bash_binaries(), reason="bash not on PATH")
def test_trusted_project_env_discovered_from_nested_cwd(bash_path: str, tmp_path: Path):
"""Mirror lib/env.py: walk up from a subdirectory to the repo-root project env."""
repo = tmp_path / "repo"
nested = repo / "apps" / "web"
nested.mkdir(parents=True)
(repo / ".git").mkdir()
env_file = repo / ".claude" / "last30days.env"
env_file.parent.mkdir(parents=True)
env_file.write_text(
"SETUP_COMPLETE=true\nSCRAPECREATORS_API_KEY=from-repo-root\n",
encoding="utf-8",
)
env_file.chmod(0o644)
result = _run_hook(
bash_path,
nested,
tmp_path,
{
"LAST30DAYS_TRUST_PROJECT_CONFIG": "1",
"LAST30DAYS_CONFIG_DIR": str(tmp_path / "empty-config"),
"LAST30DAYS_MEMORY_DIR": str(tmp_path / "memory"),
},
)
assert result.returncode == 0, result.stderr
assert re.search(r"Ready — \d+ sources active", result.stdout)
assert "Tip: Add ScrapeCreators" not in result.stdout
_assert_mode(env_file, "600")
@pytest.mark.skipif(not _bash_binaries(), reason="bash not on PATH")
def test_project_env_walk_stops_at_git_root(bash_path: str, tmp_path: Path):
"""An env above the git root must not be discovered (matches lib/env.py)."""
outside = tmp_path / ".claude" / "last30days.env"
outside.parent.mkdir(parents=True)
outside.write_text(
"SETUP_COMPLETE=true\nSCRAPECREATORS_API_KEY=outside-repo\n",
encoding="utf-8",
)
outside.chmod(0o644)
repo = tmp_path / "repo"
nested = repo / "nested"
nested.mkdir(parents=True)
(repo / ".git").mkdir()
result = _run_hook(
bash_path,
nested,
tmp_path,
{
"LAST30DAYS_TRUST_PROJECT_CONFIG": "1",
"LAST30DAYS_CONFIG_DIR": str(tmp_path / "empty-config"),
"LAST30DAYS_MEMORY_DIR": str(tmp_path / "memory"),
},
)
assert result.returncode == 0, result.stderr
_assert_mode(outside, "644")
# Outside key must not suppress the ScrapeCreators tip / must not count as configured.
if "sources active" in result.stdout:
assert "Tip: Add ScrapeCreators" in result.stdout
else:
assert "Ready to use" in result.stdout
@pytest.mark.skipif(not _bash_binaries(), reason="bash not on PATH")
def test_malicious_key_in_global_env_also_blocked(bash_path: str, tmp_path: Path):
"""Identifier gate applies to the global file too (defense in depth)."""
config_dir = tmp_path / "config"
config_dir.mkdir()
(config_dir / ".env").write_text(f"SETUP_COMPLETE=1\n{POC_LINE}", encoding="utf-8")
work = tmp_path / "workdir"
work.mkdir()
proof = work / "RCE-PROOF.txt"
result = _run_hook(
bash_path,
work,
tmp_path,
{
"LAST30DAYS_CONFIG_DIR": str(config_dir),
"LAST30DAYS_MEMORY_DIR": str(tmp_path / "memory"),
},
)
assert not proof.exists(), f"RCE proof created from global env under {bash_path}"
assert result.returncode == 0, result.stderr
assert "Ready" in result.stdout
@pytest.mark.skipif(
not any(_bash_major(b) >= 4 for b in _bash_binaries()),
reason="needs bash 4+ to exercise printf -v RCE",
)
def test_rce_path_exercised_on_modern_bash(tmp_path: Path):
"""Sanity: at least one bash>=4 is under test so the PoC path is real, not vacuous."""
modern = [b for b in _bash_binaries() if _bash_major(b) >= 4]
assert modern, "expected a bash 4+ binary from _bash_binaries()"
test_malicious_key_blocked_even_when_project_trusted(modern[0], tmp_path)