返回 last30days-skill
test_check_config_env_safety.py
根目录 / tests / test_check_config_env_safety.py
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
407 lines PYTHON