diff --git a/vulnhunter-agent/agent/clone.py b/vulnhunter-agent/agent/clone.py index 3443b5a..83599e7 100644 --- a/vulnhunter-agent/agent/clone.py +++ b/vulnhunter-agent/agent/clone.py @@ -111,12 +111,13 @@ def shallow_clone( if _GIT_EXECUTABLE is None: raise RuntimeError("git not on PATH; cannot clone") try: - # nosec B603 — argv is statically constructed ("clone --progress - # --depth 1 --"); effective_url comes from the agent's own URL + # nosec B603 — argv is statically constructed ("clone --depth 1 + # --"); effective_url comes from the agent's own URL # validation + token injection; target is a Path the agent # owns. Absolute git path resolved at module load (kills B607). result = subprocess.run( # nosec B603 - [_GIT_EXECUTABLE, "clone", "--progress", "--depth", "1", "--", effective_url, str(target)], + [_GIT_EXECUTABLE, "clone", "--depth", "1", "--", effective_url, str(target)], + capture_output=True, text=True, timeout=timeout_seconds, env=env, @@ -132,8 +133,8 @@ def shallow_clone( if target.exists(): shutil.rmtree(target, ignore_errors=True) raise RuntimeError( - f"git clone failed (exit {result.returncode}) for {redact(repo_url)}; " - "see git output above" + f"git clone failed (exit {result.returncode}) for {redact(repo_url)}: " + f"{redact(result.stderr.strip())}" ) # Strip the token from the remote URL stored in .git/config. Without diff --git a/vulnhunter-agent/tests/test_clone.py b/vulnhunter-agent/tests/test_clone.py index 86a71b9..d21e4df 100644 --- a/vulnhunter-agent/tests/test_clone.py +++ b/vulnhunter-agent/tests/test_clone.py @@ -44,10 +44,10 @@ def test_derive(self, url: str, expected: str) -> None: class _FakeCompleted: - def __init__(self, returncode: int = 0) -> None: + def __init__(self, returncode: int = 0, stderr: str = "") -> None: self.returncode = returncode self.stdout = "" - self.stderr = "" + self.stderr = stderr @pytest.fixture @@ -142,11 +142,15 @@ def test_subprocess_nonzero_cleanup_and_runtime_error( def fake_run(cmd: list[str], **kwargs: Any) -> _FakeCompleted: target = Path(cmd[-1]) target.mkdir(parents=True, exist_ok=True) - return _FakeCompleted(returncode=128) + return _FakeCompleted( + returncode=128, + stderr="fatal: repository 'https://github.com/org/myrepo' not found", + ) monkeypatch.setattr(clone_mod.subprocess, "run", fake_run) - with pytest.raises(RuntimeError, match="git clone failed"): + with pytest.raises(RuntimeError, match="git clone failed") as exc: shallow_clone("https://github.com/org/myrepo", tmp_path) + assert "repository" in str(exc.value) assert not (tmp_path / "myrepo").exists() def test_git_terminal_prompt_env_set( @@ -244,6 +248,29 @@ def fake_run(cmd: list[str], **kwargs: Any) -> _FakeCompleted: assert "secret" not in msg assert "***@github.com" in msg + def test_stderr_redacted_in_error_message( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + def fake_run(cmd: list[str], **kwargs: Any) -> _FakeCompleted: + target = Path(cmd[-1]) + target.mkdir(parents=True, exist_ok=True) + return _FakeCompleted( + returncode=128, + stderr="fatal: Authentication failed for 'https://x-access-token:ghp_SECRET@github.com/org/repo'", + ) + + monkeypatch.setattr(clone_mod.subprocess, "run", fake_run) + with pytest.raises(RuntimeError) as exc: + shallow_clone( + "https://github.com/org/repo", + tmp_path, + ) + msg = str(exc.value) + assert "ghp_SECRET" not in msg + assert "Authentication failed" in msg + def test_url_redacted_in_timeout_error_message( self, tmp_path: Path, @@ -306,5 +333,7 @@ def fake_run(cmd: list[str], **kwargs: Any) -> _FakeCompleted: "https://github.com/org/myrepo", tmp_path, ) - assert captured[0][0] == "/usr/bin/git" - assert captured[0][1] == "clone" + assert captured[0] == [ + "/usr/bin/git", "clone", "--depth", "1", + "--", "https://github.com/org/myrepo", str(tmp_path / "myrepo"), + ]