| 1 | """Security tests for hooks/scripts/check-config.sh env parsing. |
| 2 | |
| 3 | Covers the SessionStart hook's .env loader: |
| 4 | |
| 5 | - printf -v key injection (array-subscript command substitution) |
| 6 | - LAST30DAYS_TRUST_PROJECT_CONFIG gate matching lib/env.py |
| 7 | - Legitimate identifier keys still parse |
| 8 | |
| 9 | The PoC key ``x[$(touch RCE-PROOF.txt)]=1`` executes under bash 4+/5 via |
| 10 | printf -v assignment semantics; bash 3.2 rejects it as an invalid identifier |
| 11 | and (with ``set -e``) aborts the hook. Either way, after the fix the hook must |
| 12 | exit 0 and must never create the proof file. |
| 13 | """ |
| 14 | |
| 15 | from __future__ import annotations |
| 16 | |
| 17 | import os |
| 18 | import re |
| 19 | import shutil |
| 20 | import subprocess |
| 21 | from pathlib import Path |
| 22 | |
| 23 | import pytest |
| 24 | |
| 25 | HOOK = Path(__file__).resolve().parents[1] / "hooks" / "scripts" / "check-config.sh" |
| 26 | POC_LINE = "x[$(touch RCE-PROOF.txt)]=1\n" |
| 27 | |
| 28 | |
| 29 | def _bash_binaries() -> list[str]: |
| 30 | """Prefer modern bash (4+) when present so the RCE path is actually exercised.""" |
| 31 | seen: list[str] = [] |
| 32 | for candidate in ( |
| 33 | "/opt/homebrew/bin/bash", |
| 34 | "/usr/local/bin/bash", |
| 35 | shutil.which("bash"), |
| 36 | ): |
| 37 | if not candidate: |
| 38 | continue |
| 39 | path = str(Path(candidate).resolve()) |
| 40 | if path not in seen and Path(path).is_file(): |
| 41 | seen.append(path) |
| 42 | return seen |
| 43 | |
| 44 | |
| 45 | def _bash_major(bash_path: str) -> int: |
| 46 | result = subprocess.run( |
| 47 | [bash_path, "-c", 'echo "${BASH_VERSINFO[0]}"'], |
| 48 | capture_output=True, |
| 49 | text=True, |
| 50 | timeout=10, |
| 51 | check=False, |
| 52 | ) |
| 53 | try: |
| 54 | return int((result.stdout or "").strip() or "0") |
| 55 | except ValueError: |
| 56 | return 0 |
| 57 | |
| 58 | |
| 59 | def _mode_bits_assertable() -> bool: |
| 60 | """Match check_perms: Windows/MSYS synthesized modes are not meaningful.""" |
| 61 | if os.name == "nt": |
| 62 | return False |
| 63 | uname = "" |
| 64 | try: |
| 65 | uname = os.uname().sysname # type: ignore[attr-defined] |
| 66 | except AttributeError: |
| 67 | return True |
| 68 | return not uname.startswith(("MINGW", "MSYS", "CYGWIN")) |
| 69 | |
| 70 | |
| 71 | def _assert_mode(path: Path, expected: str) -> None: |
| 72 | if _mode_bits_assertable(): |
| 73 | assert oct(path.stat().st_mode)[-3:] == expected |
| 74 | |
| 75 | |
| 76 | def _isolated_path(tmp_path: Path) -> str: |
| 77 | """PATH with a stub ``security`` so macOS Keychain presence cannot leak into assertions.""" |
| 78 | bin_dir = tmp_path / "hook-bin" |
| 79 | bin_dir.mkdir(exist_ok=True) |
| 80 | security = bin_dir / "security" |
| 81 | if not security.exists(): |
| 82 | security.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") |
| 83 | security.chmod(0o755) |
| 84 | return f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}" |
| 85 | |
| 86 | |
| 87 | def _run_hook( |
| 88 | bash_path: str, |
| 89 | cwd: Path, |
| 90 | tmp_path: Path, |
| 91 | env_overrides: dict[str, str] | None = None, |
| 92 | ) -> subprocess.CompletedProcess[str]: |
| 93 | env = os.environ.copy() |
| 94 | for k in ( |
| 95 | "LAST30DAYS_MEMORY_DIR", |
| 96 | "SETUP_COMPLETE", |
| 97 | "LAST30DAYS_CONFIG_DIR", |
| 98 | "LAST30DAYS_TRUST_PROJECT_CONFIG", |
| 99 | "OPENAI_API_KEY", |
| 100 | "SCRAPECREATORS_API_KEY", |
| 101 | "AUTH_TOKEN", |
| 102 | "CT0", |
| 103 | "XAI_API_KEY", |
| 104 | "BSKY_HANDLE", |
| 105 | "EXA_API_KEY", |
| 106 | ): |
| 107 | env.pop(k, None) |
| 108 | env["PATH"] = _isolated_path(tmp_path) |
| 109 | if env_overrides: |
| 110 | env.update(env_overrides) |
| 111 | return subprocess.run( |
| 112 | [bash_path, str(HOOK)], |
| 113 | capture_output=True, |
| 114 | text=True, |
| 115 | env=env, |
| 116 | cwd=str(cwd), |
| 117 | timeout=30, |
| 118 | check=False, |
| 119 | ) |
| 120 | |
| 121 | |
| 122 | @pytest.fixture(params=_bash_binaries()) |
| 123 | def bash_path(request: pytest.FixtureRequest) -> str: |
| 124 | return request.param |
| 125 | |
| 126 | |
| 127 | @pytest.mark.skipif(not _bash_binaries(), reason="bash not on PATH") |
| 128 | def test_malicious_project_env_key_does_not_execute(bash_path: str, tmp_path: Path): |
| 129 | """Reporter PoC: crafted key must not run, even under bash 4+/5.""" |
| 130 | project = tmp_path / "repo" |
| 131 | env_file = project / ".claude" / "last30days.env" |
| 132 | env_file.parent.mkdir(parents=True) |
| 133 | env_file.write_text(f"SETUP_COMPLETE=1\n{POC_LINE}", encoding="utf-8") |
| 134 | proof = project / "RCE-PROOF.txt" |
| 135 | |
| 136 | result = _run_hook( |
| 137 | bash_path, |
| 138 | project, |
| 139 | tmp_path, |
| 140 | { |
| 141 | "LAST30DAYS_CONFIG_DIR": str(tmp_path / "empty-config"), |
| 142 | "LAST30DAYS_MEMORY_DIR": str(tmp_path / "memory"), |
| 143 | }, |
| 144 | ) |
| 145 | |
| 146 | assert not proof.exists(), f"RCE proof file was created under {bash_path}" |
| 147 | assert result.returncode == 0, ( |
| 148 | f"hook aborted under {bash_path}: stderr={result.stderr!r} stdout={result.stdout!r}" |
| 149 | ) |
| 150 | |
| 151 | |
| 152 | @pytest.mark.skipif(not _bash_binaries(), reason="bash not on PATH") |
| 153 | def test_malicious_key_blocked_even_when_project_trusted(bash_path: str, tmp_path: Path): |
| 154 | """Identifier gate must hold even after an explicit trust opt-in.""" |
| 155 | project = tmp_path / "repo" |
| 156 | env_file = project / ".claude" / "last30days.env" |
| 157 | env_file.parent.mkdir(parents=True) |
| 158 | env_file.write_text(f"SETUP_COMPLETE=1\n{POC_LINE}", encoding="utf-8") |
| 159 | proof = project / "RCE-PROOF.txt" |
| 160 | |
| 161 | result = _run_hook( |
| 162 | bash_path, |
| 163 | project, |
| 164 | tmp_path, |
| 165 | { |
| 166 | "LAST30DAYS_TRUST_PROJECT_CONFIG": "1", |
| 167 | "LAST30DAYS_CONFIG_DIR": str(tmp_path / "empty-config"), |
| 168 | "LAST30DAYS_MEMORY_DIR": str(tmp_path / "memory"), |
| 169 | }, |
| 170 | ) |
| 171 | |
| 172 | assert not proof.exists(), f"RCE proof file was created under {bash_path} (trusted path)" |
| 173 | assert result.returncode == 0, ( |
| 174 | f"hook aborted under {bash_path}: stderr={result.stderr!r} stdout={result.stdout!r}" |
| 175 | ) |
| 176 | assert "Ready" in result.stdout |
| 177 | |
| 178 | |
| 179 | @pytest.mark.skipif(not _bash_binaries(), reason="bash not on PATH") |
| 180 | def test_untrusted_project_env_is_ignored(bash_path: str, tmp_path: Path): |
| 181 | """Without LAST30DAYS_TRUST_PROJECT_CONFIG, project file is not read or chmod'd.""" |
| 182 | project = tmp_path / "repo" |
| 183 | env_file = project / ".claude" / "last30days.env" |
| 184 | env_file.parent.mkdir(parents=True) |
| 185 | env_file.write_text( |
| 186 | "SETUP_COMPLETE=true\nSCRAPECREATORS_API_KEY=scrape-test-key-untrusted\n", |
| 187 | encoding="utf-8", |
| 188 | ) |
| 189 | env_file.chmod(0o644) |
| 190 | |
| 191 | result = _run_hook( |
| 192 | bash_path, |
| 193 | project, |
| 194 | tmp_path, |
| 195 | { |
| 196 | "LAST30DAYS_CONFIG_DIR": str(tmp_path / "empty-config"), |
| 197 | "LAST30DAYS_MEMORY_DIR": str(tmp_path / "memory"), |
| 198 | }, |
| 199 | ) |
| 200 | |
| 201 | assert result.returncode == 0, result.stderr |
| 202 | # check_perms only runs on the chosen config file; untrusted project stays 644. |
| 203 | _assert_mode(env_file, "644") |
| 204 | # Project ScrapeCreators key must not suppress the tip when the configured |
| 205 | # banner path runs (isolated PATH stubs Keychain). |
| 206 | if "sources active" in result.stdout: |
| 207 | assert "Tip: Add ScrapeCreators" in result.stdout |
| 208 | else: |
| 209 | assert "Ready to use" in result.stdout |
| 210 | |
| 211 | |
| 212 | @pytest.mark.skipif(not _bash_binaries(), reason="bash not on PATH") |
| 213 | def test_trusted_project_env_loads_normal_keys(bash_path: str, tmp_path: Path): |
| 214 | project = tmp_path / "repo" |
| 215 | env_file = project / ".claude" / "last30days.env" |
| 216 | env_file.parent.mkdir(parents=True) |
| 217 | env_file.write_text( |
| 218 | "SETUP_COMPLETE=true\n" |
| 219 | "SCRAPECREATORS_API_KEY=scrape-test-key\n", |
| 220 | encoding="utf-8", |
| 221 | ) |
| 222 | env_file.chmod(0o644) |
| 223 | |
| 224 | result = _run_hook( |
| 225 | bash_path, |
| 226 | project, |
| 227 | tmp_path, |
| 228 | { |
| 229 | "LAST30DAYS_TRUST_PROJECT_CONFIG": "1", |
| 230 | "LAST30DAYS_CONFIG_DIR": str(tmp_path / "empty-config"), |
| 231 | "LAST30DAYS_MEMORY_DIR": str(tmp_path / "memory"), |
| 232 | }, |
| 233 | ) |
| 234 | |
| 235 | assert result.returncode == 0, result.stderr |
| 236 | assert re.search(r"Ready — \d+ sources active", result.stdout) |
| 237 | assert "Tip: Add ScrapeCreators" not in result.stdout |
| 238 | _assert_mode(env_file, "600") |
| 239 | |
| 240 | |
| 241 | @pytest.mark.skipif(not _bash_binaries(), reason="bash not on PATH") |
| 242 | def test_global_trust_signal_unlocks_project_env(bash_path: str, tmp_path: Path): |
| 243 | """Trust from ~/.config (via LAST30DAYS_CONFIG_DIR) unlocks project overlay.""" |
| 244 | config_dir = tmp_path / "config" |
| 245 | config_dir.mkdir() |
| 246 | (config_dir / ".env").write_text( |
| 247 | "LAST30DAYS_TRUST_PROJECT_CONFIG=1\nSETUP_COMPLETE=true\n", |
| 248 | encoding="utf-8", |
| 249 | ) |
| 250 | |
| 251 | project = tmp_path / "repo" |
| 252 | env_file = project / ".claude" / "last30days.env" |
| 253 | env_file.parent.mkdir(parents=True) |
| 254 | env_file.write_text("SCRAPECREATORS_API_KEY=from-project\n", encoding="utf-8") |
| 255 | env_file.chmod(0o644) |
| 256 | |
| 257 | result = _run_hook( |
| 258 | bash_path, |
| 259 | project, |
| 260 | tmp_path, |
| 261 | { |
| 262 | "LAST30DAYS_CONFIG_DIR": str(config_dir), |
| 263 | "LAST30DAYS_MEMORY_DIR": str(tmp_path / "memory"), |
| 264 | }, |
| 265 | ) |
| 266 | |
| 267 | assert result.returncode == 0, result.stderr |
| 268 | assert re.search(r"Ready — \d+ sources active", result.stdout) |
| 269 | assert "Tip: Add ScrapeCreators" not in result.stdout |
| 270 | _assert_mode(env_file, "600") |
| 271 | |
| 272 | |
| 273 | @pytest.mark.skipif(not _bash_binaries(), reason="bash not on PATH") |
| 274 | def test_process_deny_overrides_global_trust(bash_path: str, tmp_path: Path): |
| 275 | config_dir = tmp_path / "config" |
| 276 | config_dir.mkdir() |
| 277 | (config_dir / ".env").write_text( |
| 278 | "LAST30DAYS_TRUST_PROJECT_CONFIG=1\nSETUP_COMPLETE=true\n", |
| 279 | encoding="utf-8", |
| 280 | ) |
| 281 | |
| 282 | project = tmp_path / "repo" |
| 283 | env_file = project / ".claude" / "last30days.env" |
| 284 | env_file.parent.mkdir(parents=True) |
| 285 | env_file.write_text("SCRAPECREATORS_API_KEY=from-project\n", encoding="utf-8") |
| 286 | env_file.chmod(0o644) |
| 287 | |
| 288 | result = _run_hook( |
| 289 | bash_path, |
| 290 | project, |
| 291 | tmp_path, |
| 292 | { |
| 293 | "LAST30DAYS_TRUST_PROJECT_CONFIG": "0", |
| 294 | "LAST30DAYS_CONFIG_DIR": str(config_dir), |
| 295 | "LAST30DAYS_MEMORY_DIR": str(tmp_path / "memory"), |
| 296 | }, |
| 297 | ) |
| 298 | |
| 299 | assert result.returncode == 0, result.stderr |
| 300 | assert re.search(r"Ready — \d+ sources active", result.stdout) |
| 301 | assert "Tip: Add ScrapeCreators" in result.stdout |
| 302 | _assert_mode(env_file, "644") |
| 303 | |
| 304 | |
| 305 | @pytest.mark.skipif(not _bash_binaries(), reason="bash not on PATH") |
| 306 | def test_trusted_project_env_discovered_from_nested_cwd(bash_path: str, tmp_path: Path): |
| 307 | """Mirror lib/env.py: walk up from a subdirectory to the repo-root project env.""" |
| 308 | repo = tmp_path / "repo" |
| 309 | nested = repo / "apps" / "web" |
| 310 | nested.mkdir(parents=True) |
| 311 | (repo / ".git").mkdir() |
| 312 | env_file = repo / ".claude" / "last30days.env" |
| 313 | env_file.parent.mkdir(parents=True) |
| 314 | env_file.write_text( |
| 315 | "SETUP_COMPLETE=true\nSCRAPECREATORS_API_KEY=from-repo-root\n", |
| 316 | encoding="utf-8", |
| 317 | ) |
| 318 | env_file.chmod(0o644) |
| 319 | |
| 320 | result = _run_hook( |
| 321 | bash_path, |
| 322 | nested, |
| 323 | tmp_path, |
| 324 | { |
| 325 | "LAST30DAYS_TRUST_PROJECT_CONFIG": "1", |
| 326 | "LAST30DAYS_CONFIG_DIR": str(tmp_path / "empty-config"), |
| 327 | "LAST30DAYS_MEMORY_DIR": str(tmp_path / "memory"), |
| 328 | }, |
| 329 | ) |
| 330 | |
| 331 | assert result.returncode == 0, result.stderr |
| 332 | assert re.search(r"Ready — \d+ sources active", result.stdout) |
| 333 | assert "Tip: Add ScrapeCreators" not in result.stdout |
| 334 | _assert_mode(env_file, "600") |
| 335 | |
| 336 | |
| 337 | @pytest.mark.skipif(not _bash_binaries(), reason="bash not on PATH") |
| 338 | def test_project_env_walk_stops_at_git_root(bash_path: str, tmp_path: Path): |
| 339 | """An env above the git root must not be discovered (matches lib/env.py).""" |
| 340 | outside = tmp_path / ".claude" / "last30days.env" |
| 341 | outside.parent.mkdir(parents=True) |
| 342 | outside.write_text( |
| 343 | "SETUP_COMPLETE=true\nSCRAPECREATORS_API_KEY=outside-repo\n", |
| 344 | encoding="utf-8", |
| 345 | ) |
| 346 | outside.chmod(0o644) |
| 347 | |
| 348 | repo = tmp_path / "repo" |
| 349 | nested = repo / "nested" |
| 350 | nested.mkdir(parents=True) |
| 351 | (repo / ".git").mkdir() |
| 352 | |
| 353 | result = _run_hook( |
| 354 | bash_path, |
| 355 | nested, |
| 356 | tmp_path, |
| 357 | { |
| 358 | "LAST30DAYS_TRUST_PROJECT_CONFIG": "1", |
| 359 | "LAST30DAYS_CONFIG_DIR": str(tmp_path / "empty-config"), |
| 360 | "LAST30DAYS_MEMORY_DIR": str(tmp_path / "memory"), |
| 361 | }, |
| 362 | ) |
| 363 | |
| 364 | assert result.returncode == 0, result.stderr |
| 365 | _assert_mode(outside, "644") |
| 366 | # Outside key must not suppress the ScrapeCreators tip / must not count as configured. |
| 367 | if "sources active" in result.stdout: |
| 368 | assert "Tip: Add ScrapeCreators" in result.stdout |
| 369 | else: |
| 370 | assert "Ready to use" in result.stdout |
| 371 | |
| 372 | |
| 373 | @pytest.mark.skipif(not _bash_binaries(), reason="bash not on PATH") |
| 374 | def test_malicious_key_in_global_env_also_blocked(bash_path: str, tmp_path: Path): |
| 375 | """Identifier gate applies to the global file too (defense in depth).""" |
| 376 | config_dir = tmp_path / "config" |
| 377 | config_dir.mkdir() |
| 378 | (config_dir / ".env").write_text(f"SETUP_COMPLETE=1\n{POC_LINE}", encoding="utf-8") |
| 379 | work = tmp_path / "workdir" |
| 380 | work.mkdir() |
| 381 | proof = work / "RCE-PROOF.txt" |
| 382 | |
| 383 | result = _run_hook( |
| 384 | bash_path, |
| 385 | work, |
| 386 | tmp_path, |
| 387 | { |
| 388 | "LAST30DAYS_CONFIG_DIR": str(config_dir), |
| 389 | "LAST30DAYS_MEMORY_DIR": str(tmp_path / "memory"), |
| 390 | }, |
| 391 | ) |
| 392 | |
| 393 | assert not proof.exists(), f"RCE proof created from global env under {bash_path}" |
| 394 | assert result.returncode == 0, result.stderr |
| 395 | assert "Ready" in result.stdout |
| 396 | |
| 397 | |
| 398 | @pytest.mark.skipif( |
| 399 | not any(_bash_major(b) >= 4 for b in _bash_binaries()), |
| 400 | reason="needs bash 4+ to exercise printf -v RCE", |
| 401 | ) |
| 402 | def test_rce_path_exercised_on_modern_bash(tmp_path: Path): |
| 403 | """Sanity: at least one bash>=4 is under test so the PoC path is real, not vacuous.""" |
| 404 | modern = [b for b in _bash_binaries() if _bash_major(b) >= 4] |
| 405 | assert modern, "expected a bash 4+ binary from _bash_binaries()" |
| 406 | test_malicious_key_blocked_even_when_project_trusted(modern[0], tmp_path) |
| 407 |