Skip to content

Commit 2a937a8

Browse files
committed
Repo param injection, stats error handling, stack trace leakage, dispatch logging
1 parent 9304f64 commit 2a937a8

3 files changed

Lines changed: 42 additions & 5 deletions

File tree

src/github_client.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def _client(token: str | None = None) -> Github:
4545

4646

4747
def _default_org() -> str | None:
48-
return os.getenv("GITHUB_DEFAULT_ORG") or None
48+
return os.getenv("GITHUB_DEFAULT_ORG")
4949

5050

5151
def _repo(g: Github, owner: str, repo: str) -> Repository:
@@ -262,6 +262,11 @@ def search_code(query: str, repo: str | None = None) -> list[dict[str, Any]]:
262262
263263
Returns a list of result dicts.
264264
"""
265+
if repo is not None:
266+
_REPO_RE = re.compile(r"^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$")
267+
if not _REPO_RE.match(repo):
268+
raise ValueError("Invalid repo format: must be owner/repo")
269+
265270
g = _client()
266271
safe_query = _sanitise_query(query)
267272
full_query = f"{safe_query} repo:{repo}" if repo else safe_query
@@ -420,9 +425,9 @@ def get_weekly_digest(owner: str, repo: str) -> dict[str, Any]:
420425
{"login": login, "commits": commits} for login, commits in weekly[:5]
421426
]
422427
except RateLimitExceededException:
423-
pass # contributor stats are best-effort in the digest
428+
top_contributors = {"available": False, "reason": "stats_unavailable"}
424429
except GithubException:
425-
pass # stats may not be ready on newly created repos
430+
top_contributors = {"available": False, "reason": "stats_unavailable"}
426431

427432
return {
428433
"period": {"from": since.isoformat(), "to": now.isoformat()},

src/server.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import asyncio
1111
import json
12+
import logging
1213
import traceback
1314
from typing import Any
1415

@@ -461,6 +462,7 @@ def _handle_get_weekly_digest(args: dict[str, Any]) -> str:
461462

462463
def _dispatch(name: str, args: dict[str, Any]) -> str:
463464
"""Synchronous dispatcher — called inside an executor."""
465+
logging.info(f"Tool called: {name}")
464466
handler = _TOOL_HANDLERS.get(name)
465467
if handler is None:
466468
known = ", ".join(sorted(_TOOL_HANDLERS))
@@ -484,8 +486,9 @@ async def handle_call_tool(
484486
error_text = f"Configuration error: {exc}"
485487
except RuntimeError as exc:
486488
error_text = f"GitHub API error: {exc}"
487-
except Exception:
488-
error_text = f"Unexpected error:\n{traceback.format_exc()}"
489+
except Exception as exc:
490+
logging.exception("Unexpected error in tool dispatch")
491+
error_text = f"Error: {type(exc).__name__}: {exc}"
489492

490493
return [types.TextContent(type="text", text=error_text)]
491494

tests/test_github_client.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,35 @@ def test_rate_limit_exception_raises_runtime_with_reset_info(self):
523523
with pytest.raises(RuntimeError, match="rate limit"):
524524
gh.search_code("test")
525525

526+
def test_repo_param_injection_raises_value_error(self):
527+
"""A repo value containing injection payload must raise ValueError, not reach GitHub."""
528+
mock_g = MagicMock()
529+
mock_g.search_code.return_value = iter([])
530+
531+
with patch("src.github_client._client", return_value=mock_g):
532+
from src import github_client as gh
533+
534+
with pytest.raises(ValueError, match="Invalid repo format"):
535+
gh.search_code("foo", repo="owner/repo repo:evil/bad")
536+
537+
# The malicious string must never have been forwarded to the GitHub API
538+
mock_g.search_code.assert_not_called()
539+
540+
def test_valid_repo_param_accepted(self):
541+
"""A well-formed owner/repo string must pass validation without error."""
542+
mock_g = MagicMock()
543+
mock_g.search_code.return_value = iter([])
544+
545+
with patch("src.github_client._client", return_value=mock_g):
546+
from src import github_client as gh
547+
548+
# Should not raise
549+
gh.search_code("foo", repo="valid-owner/valid.repo_name")
550+
551+
mock_g.search_code.assert_called_once()
552+
sent_query = mock_g.search_code.call_args[0][0]
553+
assert "repo:valid-owner/valid.repo_name" in sent_query
554+
526555

527556
# ---------------------------------------------------------------------------
528557
# get_contributor_stats

0 commit comments

Comments
 (0)