From 50c3986b50ac4f4b1831e620d8f2184bc7474033 Mon Sep 17 00:00:00 2001 From: Chenhao Tan Date: Sun, 1 Mar 2026 00:08:22 +0000 Subject: [PATCH] Sanitize GitHub token from git clone command lines Use GIT_ASKPASS with a temporary helper script to pass credentials instead of embedding the token directly in the clone URL. The token is passed via the GIT_TOKEN environment variable, keeping it out of process listings, shell history, and logs. Closes #10 Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- minbot/github.py | 28 ++++++++++++++++++++++++---- tests/test_github.py | 8 +++++++- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4ebd162..a5a72ab 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A lightweight Telegram bot that monitors GitHub issues, estimates difficulty/urgency, suggests what to work on, and can autonomously work on issues using Claude Code. -📏 Core bot in **1171 lines** of Python (run `bash core_lines.sh` to verify) +📏 Core bot in **1191 lines** of Python (run `bash core_lines.sh` to verify) ## Quick Start diff --git a/minbot/github.py b/minbot/github.py index 1e91f6c..139ab8a 100644 --- a/minbot/github.py +++ b/minbot/github.py @@ -1,7 +1,9 @@ """GitHub operations via PyGithub + git CLI.""" import os +import stat import subprocess +import tempfile from github import Github _client: Github | None = None @@ -162,6 +164,27 @@ def add_pr_comment(repo: str, number: int, body: str) -> None: _get_repo(repo).get_issue(number).create_comment(body) +def _git_clone_with_token(repo: str, path: str) -> None: + """Clone using GIT_ASKPASS to avoid embedding the token in the command line.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as f: + f.write("#!/bin/sh\n" + "case \"$1\" in\n" + " *Username*) echo x-access-token;;\n" + f" *Password*) echo \"$GIT_TOKEN\";;\n" + "esac\n") + askpass = f.name + os.chmod(askpass, stat.S_IRWXU) + try: + env = {**os.environ, "GIT_ASKPASS": askpass, + "GIT_TERMINAL_PROMPT": "0", "GIT_TOKEN": _token} + subprocess.run( + ["git", "clone", f"https://github.com/{repo}.git", path], + check=True, capture_output=True, env=env, + ) + finally: + os.unlink(askpass) + + def clone_repo(repo: str, path: str) -> None: """Clone a repo, or if already cloned, checkout main and pull.""" if os.path.exists(os.path.join(path, ".git")): @@ -180,7 +203,4 @@ def clone_repo(repo: str, path: str) -> None: ) else: os.makedirs(os.path.dirname(path), exist_ok=True) - subprocess.run( - ["git", "clone", f"https://x-access-token:{_token}@github.com/{repo}.git", path], - check=True, capture_output=True, - ) + _git_clone_with_token(repo, path) diff --git a/tests/test_github.py b/tests/test_github.py index 6cf2e22..fd07c13 100644 --- a/tests/test_github.py +++ b/tests/test_github.py @@ -117,7 +117,13 @@ def test_clone_repo_pulls_if_exists(mock_exists, mock_run): @patch("subprocess.run") @patch("os.path.exists", return_value=False) def test_clone_repo_clones_if_new(mock_exists, mock_run): + _setup_client() mock_run.return_value = MagicMock(returncode=0) - github.clone_repo("owner/repo", "/tmp/repo") + with patch("tempfile.NamedTemporaryFile", MagicMock()), \ + patch("os.chmod"), \ + patch("os.unlink"): + github.clone_repo("owner/repo", "/tmp/repo") args = mock_run.call_args[0][0] assert "clone" in args + # Token must NOT appear in the command-line arguments + assert all("fake-token" not in str(a) for a in args)