From 7c533fd0f7ccc80125251250987aed6b3e175389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nerijus=20Bend=C5=BEi=C5=ABnas?= Date: Fri, 1 May 2026 10:21:51 +0300 Subject: [PATCH] feat(signature)!: verify commits via GitHub public key lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates the need for a pre-configured local keyring by fetching the author's public GPG and SSH keys directly from GitHub at check time. Lookup flow: 1. Resolve author email → GitHub username via the search API 2. Fetch `github.com/{username}.gpg` and `github.com/{username}.keys` 3. Try GPG verification with a temporary GNUPGHOME 4. Try SSH verification with a temporary allowed_signers file 5. Fail explicitly if the API is unreachable or author not found on GitHub BREAKING CHANGE: previously, API errors and unknown authors silently fell back to `git verify-commit`. Both cases now fail with a clear error message. Disable the `signature` check if GitHub API access is unavailable. Signed-off-by: Nerijus Bendžiūnas --- README.md | 20 +++- docs/index.html | 21 +++- src/git_commit_guard/__init__.py | 120 ++++++++++++++++++--- tests/test_git_commit_guard.py | 180 ++++++++++++++++++++++++++++--- 4 files changed, 314 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 58466d8..8fe64aa 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,8 @@ Available checks: * `imperative` - First word is an imperative verb (for example `add` not `added`) * `body` - Blank line separates subject from body, and body is non-empty * `signed-off` - `Signed-off-by:` trailer exists -* `signature` - Verify GPG or SSH signature +* `signature` - Verify GPG or SSH signature via GitHub public key lookup, with + fallback to `git verify-commit` ### Subject length @@ -199,6 +200,23 @@ Trailer matching is case-sensitive and requires at least one non-space character after the colon (e.g. `Closes: #42`). This check runs independently of `--enable`/`--disable`. +### Signature verification + +The `signature` check tries to verify the commit without any local keyring setup: + +1. Look up the commit author's email in the GitHub API to find their GitHub + username. +2. Fetch their public keys from `github.com/{username}.gpg` and + `github.com/{username}.keys`. +3. Try GPG verification: import the fetched key into a temporary keyring and + run `git verify-commit`. +4. Try SSH verification: write a temporary `allowed_signers` file and run + `git verify-commit` with the SSH allowed-signers config. +5. If any key verifies, the check passes. If none do, it fails. + +If the author's email is not found on GitHub, or the API is unreachable, the +check fails with a clear error — there is no silent fallback. + ### Configuration file Place `.commit-guard.toml` in your project root (or any parent directory) to diff --git a/docs/index.html b/docs/index.html index 4944a1e..9a9e785 100644 --- a/docs/index.html +++ b/docs/index.html @@ -377,7 +377,7 @@

Checks

signature - GPG or SSH signature is valid + GPG or SSH signature is valid — verified via GitHub public key lookup @@ -426,6 +426,25 @@

Required trailers

commit-guard --require-trailer "Closes,Reviewed-by"
+

Signature verification

+

+ The signature check verifies commits without requiring a + pre-configured local keyring: +

+
    +
  1. Look up the commit author's email via the GitHub search API to find their username.
  2. +
  3. Fetch their public keys from github.com/{username}.gpg and github.com/{username}.keys.
  4. +
  5. Try GPG verification using a temporary keyring.
  6. +
  7. Try SSH verification using a temporary allowed_signers file.
  8. +
  9. Pass if any key verifies; fail if none do.
  10. +
+

+ If the author's email is not found on GitHub, or the API is + unreachable, the check fails with a clear error. Disable the + signature check if GitHub API access is unavailable: +

+
commit-guard --disable signature
+

Range options

When using --range, merge commits are excluded by diff --git a/src/git_commit_guard/__init__.py b/src/git_commit_guard/__init__.py index d0fa9c0..9566a7d 100644 --- a/src/git_commit_guard/__init__.py +++ b/src/git_commit_guard/__init__.py @@ -4,7 +4,10 @@ import re import subprocess import sys +import tempfile import tomllib +import urllib.error +import urllib.request from argparse import ArgumentParser from dataclasses import dataclass, field from enum import StrEnum @@ -256,21 +259,114 @@ def check_required_trailers(message, required, result): result.error(f"missing required trailer: {trailer}") -def check_signature(rev, result): - proc = subprocess.run( # noqa: S603 - ["git", "verify-commit", rev], # noqa: S607 - capture_output=True, +def _get_author_email(rev): + return subprocess.check_output( # noqa: S603 + ["git", "log", "-1", "--format=%ae", rev], # noqa: S607 text=True, - check=False, + stderr=subprocess.PIPE, timeout=_git_timeout(), - ) - if proc.returncode != 0: - result.error("commit is not signed (GPG/SSH)", check=Check.SIGNATURE) - return + ).strip() + + +def _fetch_github_username(email): + url = f"https://api.github.com/search/users?q={email}+in:email" + req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) # noqa: S310 Audit URL open for permitted schemes + with urllib.request.urlopen(req, timeout=_git_timeout()) as resp: # noqa: S310 Audit URL open for permitted schemes + data = json.loads(resp.read()) + items = data.get("items", []) + return items[0]["login"] if items else None + + +def _fetch_url(url): + with urllib.request.urlopen(url, timeout=_git_timeout()) as resp: # noqa: S310 + return resp.read().decode() - output = proc.stderr.lower() - sig_type = "SSH" if "ssh" in output else "GPG" - result.info(f"signature type: {sig_type}", check=Check.SIGNATURE) + +def _fetch_github_keys(username): + gpg = _fetch_url(f"https://github.com/{username}.gpg") + ssh = _fetch_url(f"https://github.com/{username}.keys") + return gpg.strip(), ssh.strip() + + +def _verify_gpg(rev, gpg_text): + if not gpg_text: + return False + with tempfile.TemporaryDirectory() as homedir: + env = {**os.environ, "GNUPGHOME": homedir} + import_proc = subprocess.run( + ["gpg", "--batch", "--import"], # noqa: S607 + input=gpg_text, + text=True, + capture_output=True, + env=env, + check=False, + ) + if import_proc.returncode != 0: + return False + verify_proc = subprocess.run( # noqa: S603 + ["git", "verify-commit", rev], # noqa: S607 + capture_output=True, + text=True, + env=env, + check=False, + timeout=_git_timeout(), + ) + return verify_proc.returncode == 0 + + +def _verify_ssh(rev, email, ssh_text): + if not ssh_text: + return False + with tempfile.NamedTemporaryFile( + mode="w", suffix=".allowedSigners", delete=False + ) as f: + for raw_line in ssh_text.splitlines(): + stripped = raw_line.strip() + if stripped: + f.write(f"{email} {stripped}\n") + signers_path = f.name + try: + proc = subprocess.run( # noqa: S603 + [ # noqa: S607 + "git", + "-c", + f"gpg.ssh.allowedSignersFile={signers_path}", + "verify-commit", + rev, + ], + capture_output=True, + text=True, + check=False, + timeout=_git_timeout(), + ) + return proc.returncode == 0 + finally: + Path(signers_path).unlink(missing_ok=True) + + +def check_signature(rev, result): + try: + email = _get_author_email(rev) + username = _fetch_github_username(email) + if username is None: + result.error( + "commit author not found on GitHub — cannot verify signature", + check=Check.SIGNATURE, + ) + return + gpg_text, ssh_text = _fetch_github_keys(username) + if _verify_gpg(rev, gpg_text): + result.info("signature type: GPG", check=Check.SIGNATURE) + return + if _verify_ssh(rev, email, ssh_text): + result.info("signature type: SSH", check=Check.SIGNATURE) + return + result.error("commit is not signed (GPG/SSH)", check=Check.SIGNATURE) + except (urllib.error.URLError, TimeoutError): + result.error( + "GitHub API unreachable — cannot verify signature", + check=Check.SIGNATURE, + ) def _get_message(rev): diff --git a/tests/test_git_commit_guard.py b/tests/test_git_commit_guard.py index 392c1ba..013c1a6 100644 --- a/tests/test_git_commit_guard.py +++ b/tests/test_git_commit_guard.py @@ -1,6 +1,7 @@ import json import re import subprocess +import urllib.error from argparse import ArgumentParser, Namespace from unittest.mock import MagicMock, patch @@ -13,6 +14,10 @@ Result, _download_if_missing, _ensure_nltk_data, + _fetch_github_keys, + _fetch_github_username, + _fetch_url, + _get_author_email, _get_message, _get_range_revs, _git_timeout, @@ -29,6 +34,8 @@ _resolve_subject_pattern, _resolve_types, _strip_comments, + _verify_gpg, + _verify_ssh, check_body, check_imperative, check_required_trailers, @@ -540,30 +547,173 @@ def test_downloads_when_missing(self): mock_dl.assert_called_once_with("punkt_tab", quiet=True) -class TestCheckSignature: - def test_unsigned_commit(self): - r = Result() +class TestGetAuthorEmail: + def test_returns_stripped_email(self): + with patch( + "git_commit_guard.subprocess.check_output", + return_value="user@example.com\n", + ): + assert _get_author_email("abc123") == "user@example.com" + + +class TestFetchUrl: + def test_returns_decoded_content(self): + mock_resp = MagicMock() + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + mock_resp.read.return_value = b"key data" + with patch("git_commit_guard.urllib.request.urlopen", return_value=mock_resp): + assert _fetch_url("https://github.com/user.keys") == "key data" + + +class TestFetchGithubUsername: + def _mock_response(self, data): + mock_resp = MagicMock() + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + mock_resp.read.return_value = json.dumps(data).encode() + return mock_resp + + def test_found_returns_login(self): + resp = self._mock_response({"items": [{"login": "testuser"}]}) + with patch("git_commit_guard.urllib.request.urlopen", return_value=resp): + assert _fetch_github_username("test@example.com") == "testuser" + + def test_not_found_returns_none(self): + resp = self._mock_response({"items": []}) + with patch("git_commit_guard.urllib.request.urlopen", return_value=resp): + assert _fetch_github_username("unknown@example.com") is None + + +class TestFetchGithubKeys: + def test_returns_gpg_and_ssh(self): + with patch( + "git_commit_guard._fetch_url", side_effect=["GPG KEY\n", "SSH KEY\n"] + ): + gpg, ssh = _fetch_github_keys("testuser") + assert gpg == "GPG KEY" + assert ssh == "SSH KEY" + + +class TestVerifyGpg: + def test_empty_gpg_returns_false(self): + assert _verify_gpg("abc123", "") is False + + def test_import_failure_returns_false(self): proc = MagicMock(returncode=1) with patch("git_commit_guard.subprocess.run", return_value=proc): - check_signature("abc123", r) - assert not r.ok + assert _verify_gpg("abc123", "gpg key data") is False - def test_gpg_signed_commit(self): - r = Result() - proc = MagicMock(returncode=0, stderr="gpg signature verified") + def test_verify_success_returns_true(self): + import_proc = MagicMock(returncode=0) + verify_proc = MagicMock(returncode=0) + with patch( + "git_commit_guard.subprocess.run", side_effect=[import_proc, verify_proc] + ): + assert _verify_gpg("abc123", "gpg key data") is True + + def test_verify_failure_returns_false(self): + import_proc = MagicMock(returncode=0) + verify_proc = MagicMock(returncode=1) + with patch( + "git_commit_guard.subprocess.run", side_effect=[import_proc, verify_proc] + ): + assert _verify_gpg("abc123", "gpg key data") is False + + +class TestVerifySSH: + def test_empty_ssh_returns_false(self): + assert _verify_ssh("abc123", "user@example.com", "") is False + + def test_verify_success_returns_true(self): + proc = MagicMock(returncode=0) with patch("git_commit_guard.subprocess.run", return_value=proc): + assert ( + _verify_ssh("abc123", "user@example.com", "ssh-ed25519 AAAA...") is True + ) + + def test_verify_failure_returns_false(self): + proc = MagicMock(returncode=1) + with patch("git_commit_guard.subprocess.run", return_value=proc): + assert ( + _verify_ssh("abc123", "user@example.com", "ssh-ed25519 AAAA...") + is False + ) + + +class TestCheckSignature: + def test_gpg_verified_via_github(self): + r = Result() + with ( + patch( + "git_commit_guard._get_author_email", return_value="user@example.com" + ), + patch("git_commit_guard._fetch_github_username", return_value="testuser"), + patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")), + patch("git_commit_guard._verify_gpg", return_value=True), + ): check_signature("abc123", r) assert r.ok assert any("GPG" in msg for _, _, msg in r.errors) - def test_ssh_signed_commit(self): + def test_ssh_verified_via_github(self): r = Result() - proc = MagicMock(returncode=0, stderr="Good ssh signature") - with patch("git_commit_guard.subprocess.run", return_value=proc): + with ( + patch( + "git_commit_guard._get_author_email", return_value="user@example.com" + ), + patch("git_commit_guard._fetch_github_username", return_value="testuser"), + patch("git_commit_guard._fetch_github_keys", return_value=("", "SSH KEY")), + patch("git_commit_guard._verify_gpg", return_value=False), + patch("git_commit_guard._verify_ssh", return_value=True), + ): check_signature("abc123", r) assert r.ok assert any("SSH" in msg for _, _, msg in r.errors) + def test_no_matching_key_fails(self): + r = Result() + with ( + patch( + "git_commit_guard._get_author_email", return_value="user@example.com" + ), + patch("git_commit_guard._fetch_github_username", return_value="testuser"), + patch("git_commit_guard._fetch_github_keys", return_value=("GPG", "SSH")), + patch("git_commit_guard._verify_gpg", return_value=False), + patch("git_commit_guard._verify_ssh", return_value=False), + ): + check_signature("abc123", r) + assert not r.ok + + def test_username_not_found_fails(self): + r = Result() + with ( + patch( + "git_commit_guard._get_author_email", return_value="user@example.com" + ), + patch("git_commit_guard._fetch_github_username", return_value=None), + ): + check_signature("abc123", r) + assert not r.ok + assert any("not found on GitHub" in msg for _, _, msg in r.errors) + + def test_url_error_fails(self): + r = Result() + with patch( + "git_commit_guard._get_author_email", + side_effect=urllib.error.URLError("unreachable"), + ): + check_signature("abc123", r) + assert not r.ok + assert any("API unreachable" in msg for _, _, msg in r.errors) + + def test_timeout_error_fails(self): + r = Result() + with patch("git_commit_guard._get_author_email", side_effect=TimeoutError()): + check_signature("abc123", r) + assert not r.ok + assert any("API unreachable" in msg for _, _, msg in r.errors) + class TestGetMessage: def test_success(self): @@ -929,11 +1079,15 @@ def test_imperative_only_no_subject_check(self, tmp_path): assert main() == 0 def test_signature_with_rev(self): - proc = MagicMock(returncode=0, stderr="gpg signature verified") with ( patch("sys.argv", ["cg", "abc123", "--enable", "signature"]), patch("git_commit_guard._get_message", return_value=_VALID_MSG), - patch("git_commit_guard.subprocess.run", return_value=proc), + patch( + "git_commit_guard._get_author_email", return_value="user@example.com" + ), + patch("git_commit_guard._fetch_github_username", return_value="testuser"), + patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")), + patch("git_commit_guard._verify_gpg", return_value=True), ): assert main() == 0