conftest.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. import http.server
  2. import io
  3. import json
  4. import os
  5. import struct
  6. import subprocess
  7. import sys
  8. import sysconfig
  9. import threading
  10. import time
  11. import zipfile
  12. from pathlib import Path
  13. import pytest
  14. HOOKS_DIR = Path(__file__).resolve().parent.parent / "hooks"
  15. HOOK_SCRIPT = HOOKS_DIR / "security_reminder_hook.py"
  16. sys.path.insert(0, str(HOOKS_DIR))
  17. GIT_ENV = {
  18. "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.com",
  19. "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@example.com",
  20. "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull,
  21. }
  22. def git(cwd, *args):
  23. r = subprocess.run(
  24. ["git", *args], cwd=cwd, capture_output=True, text=True,
  25. env={**os.environ, **GIT_ENV},
  26. )
  27. assert r.returncode == 0, r.stderr
  28. return r.stdout
  29. def make_repo(path, files=None):
  30. path.mkdir(parents=True, exist_ok=True)
  31. git(path, "init", "-q", "-b", "main")
  32. git(path, "config", "commit.gpgsign", "false")
  33. for name, content in (files or {"README.md": "init\n"}).items():
  34. p = path / name
  35. p.parent.mkdir(parents=True, exist_ok=True)
  36. p.write_text(content)
  37. git(path, "add", "-A")
  38. git(path, "commit", "-q", "-m", "init")
  39. return path
  40. def commit_file(repo, name, content, msg="change"):
  41. p = repo / name
  42. p.parent.mkdir(parents=True, exist_ok=True)
  43. p.write_text(content)
  44. git(repo, "add", "-A")
  45. out = subprocess.run(
  46. ["git", "commit", "-m", msg], cwd=repo, capture_output=True, text=True,
  47. env={**os.environ, **GIT_ENV},
  48. )
  49. assert out.returncode == 0, out.stderr
  50. sha = git(repo, "rev-parse", "HEAD").strip()
  51. return sha, out.stdout + out.stderr
  52. VULN_PY = (
  53. "import subprocess\n"
  54. "def run(user):\n"
  55. " subprocess.call('ls ' + user, shell=True)\n"
  56. )
  57. @pytest.fixture
  58. def workspace(tmp_path):
  59. ws = tmp_path / "ws"
  60. ws.mkdir()
  61. repo = make_repo(ws / "sub", {"app.py": "print('hi')\n"})
  62. (ws / "node_modules" / "junk").mkdir(parents=True)
  63. return ws, repo
  64. class _Stub(http.server.BaseHTTPRequestHandler):
  65. def do_POST(self):
  66. n = int(self.headers.get("Content-Length") or 0)
  67. if n:
  68. self.rfile.read(n)
  69. self.server.calls.append(self.path)
  70. if self.server.delay:
  71. time.sleep(self.server.delay)
  72. if self.server.status == 200:
  73. vulns = list(self.server.vulns)
  74. body = json.dumps({
  75. "id": "msg_stub", "type": "message", "role": "assistant",
  76. "model": "stub", "stop_reason": "end_turn",
  77. "content": [{"type": "text", "text": json.dumps(
  78. {"hasVulnerabilities": bool(vulns), "vulnerabilities": vulns})}],
  79. "usage": {"input_tokens": 1, "output_tokens": 1},
  80. }).encode()
  81. else:
  82. body = b'{"type":"error","error":{"type":"invalid_request_error","message":"stub"}}'
  83. self.send_response(self.server.status)
  84. self.send_header("Content-Type", "application/json")
  85. self.send_header("Content-Length", str(len(body)))
  86. self.end_headers()
  87. self.wfile.write(body)
  88. do_HEAD = do_GET = do_POST
  89. def log_message(self, *a):
  90. pass
  91. @pytest.fixture
  92. def stub_api():
  93. srv = http.server.HTTPServer(("127.0.0.1", 0), _Stub)
  94. srv.calls = []
  95. srv.status = 200
  96. srv.delay = 0
  97. srv.vulns = []
  98. t = threading.Thread(target=srv.serve_forever, daemon=True)
  99. t.start()
  100. try:
  101. yield srv
  102. finally:
  103. srv.shutdown()
  104. @pytest.fixture
  105. def hook_env(tmp_path, stub_api):
  106. state = tmp_path / "state"
  107. state.mkdir()
  108. env = {k: v for k, v in os.environ.items()
  109. if k not in ("ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_REMOTE",
  110. "CLAUDE_PROJECT_DIR", "HTTP_PROXY", "HTTPS_PROXY",
  111. "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy",
  112. "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX",
  113. "CLAUDE_CODE_USE_FOUNDRY",
  114. "PLUGIN_ROOT", "SECURITY_GUIDANCE_PORTABLE_OUTPUT")}
  115. env.update(GIT_ENV)
  116. env.update({
  117. "SECURITY_WARNINGS_STATE_DIR": str(state),
  118. "ANTHROPIC_API_KEY": "test-key",
  119. "ANTHROPIC_BASE_URL": f"http://127.0.0.1:{stub_api.server_port}",
  120. "NO_PROXY": "*", "no_proxy": "*",
  121. "SG_AGENTIC_COMMIT_REVIEW": "0",
  122. # Points the plugin at a claude that does not exist, so no hook test
  123. # runs this machine's own claude.
  124. "SG_AGENTIC_CLI_PATH": str(tmp_path / "no-claude"),
  125. "SECURITY_GUIDANCE_COMMIT_REVIEW": "on",
  126. "SG_PUSH_SWEEP": "on",
  127. "PYTHONDONTWRITEBYTECODE": "1",
  128. })
  129. return env
  130. def run_hook(payload, env, python=sys.executable):
  131. r = subprocess.run(
  132. [python, str(HOOK_SCRIPT)], input=json.dumps(payload),
  133. capture_output=True, text=True, env=env, timeout=120,
  134. )
  135. return r.returncode, r.stdout, r.stderr
  136. STUB_VULN = {
  137. "filePath": "app.py", "category": "command_injection", "severity": "high",
  138. "vulnerableCode": "subprocess.call('ls ' + user, shell=True)",
  139. "description": "user input reaches a shell", "recommendation": "pass an argv list",
  140. }
  141. def metrics_of(stdout):
  142. for line in stdout.splitlines():
  143. line = line.strip()
  144. if line.startswith("{"):
  145. try:
  146. m = json.loads(line).get("metrics")
  147. except json.JSONDecodeError:
  148. continue
  149. if m is not None:
  150. return m
  151. return None
  152. def bash_payload(cwd, command, stdout="", stderr="", session_id="s1", tool_use_id=None):
  153. p = {
  154. "session_id": session_id,
  155. "hook_event_name": "PostToolUse",
  156. "tool_name": "Bash",
  157. "tool_input": {"command": command},
  158. "tool_response": {"stdout": stdout, "stderr": stderr, "interrupted": False},
  159. "cwd": str(cwd),
  160. }
  161. if tool_use_id:
  162. p["tool_use_id"] = tool_use_id
  163. return p
  164. def edit_payload(cwd, file_path, new_string="x", session_id="s1"):
  165. return {
  166. "session_id": session_id,
  167. "hook_event_name": "PostToolUse",
  168. "tool_name": "Edit",
  169. "tool_input": {"file_path": str(file_path), "old_string": "", "new_string": new_string},
  170. "tool_response": {},
  171. "cwd": str(cwd),
  172. }
  173. def stop_payload(cwd, event="Stop", session_id="s1"):
  174. return {
  175. "session_id": session_id,
  176. "hook_event_name": event,
  177. "stop_hook_active": False,
  178. "cwd": str(cwd),
  179. }
  180. def ups_payload(cwd, session_id="s1"):
  181. return {
  182. "session_id": session_id,
  183. "hook_event_name": "UserPromptSubmit",
  184. "prompt": "hi",
  185. "cwd": str(cwd),
  186. }
  187. # ── a local Anthropic-compatible endpoint the hook can be pointed at ──────
  188. class _Gateway(http.server.BaseHTTPRequestHandler):
  189. """An Anthropic-compatible endpoint. With `required` set it answers 401
  190. unless that header arrives, like a LiteLLM proxy keyed on a custom header."""
  191. def _handle(self):
  192. n = int(self.headers.get("Content-Length") or 0)
  193. if n:
  194. self.rfile.read(n)
  195. srv = self.server
  196. srv.requests.append((self.command, self.path, {k.lower(): v for k, v in self.headers.items()}))
  197. name, value = srv.required or (None, None)
  198. if name and self.headers.get(name) != value:
  199. return self._send(401, {"type": "error", "error": {
  200. "type": "authentication_error", "message": "Invalid proxy server token"}})
  201. if srv.status != 200:
  202. return self._send(srv.status, {"type": "error", "error": {"type": "api_error"}})
  203. if self.command == "GET":
  204. return self._send(200, {"data": [{"id": "claude-opus-5-5"}], "has_more": False})
  205. text = (srv.reply if srv.reply is not None else json.dumps(
  206. {"hasVulnerabilities": bool(srv.vulns), "vulnerabilities": srv.vulns}))
  207. self._send(200, {
  208. "id": "msg_stub", "type": "message", "role": "assistant",
  209. "model": "stub", "stop_reason": "end_turn",
  210. "content": [{"type": "text", "text": text}],
  211. "usage": {"input_tokens": 1, "output_tokens": 1},
  212. })
  213. do_GET = do_POST = do_HEAD = _handle
  214. def _send(self, status, payload):
  215. body = json.dumps(payload).encode()
  216. self.send_response(status)
  217. self.send_header("Content-Type", "application/json")
  218. self.send_header("Content-Length", str(len(body)))
  219. self.end_headers()
  220. self.wfile.write(body)
  221. def log_message(self, *a):
  222. pass
  223. @pytest.fixture
  224. def gateway():
  225. srv = http.server.HTTPServer(("127.0.0.1", 0), _Gateway)
  226. srv.requests, srv.required, srv.status, srv.reply, srv.vulns = [], None, 200, None, []
  227. threading.Thread(target=srv.serve_forever, daemon=True).start()
  228. try:
  229. yield srv
  230. finally:
  231. srv.shutdown()
  232. @pytest.fixture
  233. def env(hook_env, gateway):
  234. e = dict(hook_env)
  235. e["ANTHROPIC_BASE_URL"] = f"http://127.0.0.1:{gateway.server_port}"
  236. e.pop("SECURITY_REVIEW_MODEL", None)
  237. return e
  238. def _windows_exe(script):
  239. """`script` as the bytes of a Windows .exe: Windows starts neither a
  240. `#!` file nor, without a shell, a .cmd with multi-line arguments. Built
  241. the way pip builds a console script: its launcher stub, then the
  242. interpreter to run, then a zip holding the script as __main__.py."""
  243. distlib = pytest.importorskip("pip._vendor.distlib")
  244. stub = "t%s%s.exe" % ("64" if struct.calcsize("P") == 8 else "32",
  245. "-arm" if sysconfig.get_platform() == "win-arm64" else "")
  246. stub = os.path.join(os.path.dirname(distlib.__file__), stub)
  247. if not os.path.isfile(stub):
  248. pytest.skip(f"pip has no script launcher {os.path.basename(stub)}")
  249. with open(stub, "rb") as f:
  250. launcher = f.read()
  251. python = f'"{sys.executable}"' if " " in sys.executable else sys.executable
  252. archive = io.BytesIO()
  253. with zipfile.ZipFile(archive, "w") as zf:
  254. zf.writestr("__main__.py", script)
  255. return launcher + f"#!{python}\n".encode("utf-8") + archive.getvalue()
  256. def write_cli(tmp_path, name, script):
  257. """A stand-in for `claude` in `tmp_path` that runs the Python `script`;
  258. returns its path."""
  259. if os.name == "nt":
  260. cli = tmp_path / f"{name}.exe"
  261. cli.write_bytes(_windows_exe(script))
  262. else:
  263. cli = tmp_path / name
  264. cli.write_text(f"#!{sys.executable}\n{script}")
  265. cli.chmod(0o755)
  266. return cli
  267. @pytest.fixture
  268. def failing_inner_cli(tmp_path):
  269. """An inner CLI that prints a warning on stderr and exits 1; the review
  270. then falls back to a single-shot call."""
  271. return str(write_cli(tmp_path, "claude", (
  272. "import sys\n"
  273. "sys.stderr.write('claude.ai connectors are disabled\\n')\n"
  274. "sys.exit(1)\n")))
  275. @pytest.fixture
  276. def agentic_env(env, failing_inner_cli):
  277. pytest.importorskip("claude_agent_sdk")
  278. return {**env, "SG_AGENTIC_COMMIT_REVIEW": "1", "SG_AGENTIC_CLI_PATH": failing_inner_cli}