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 @@
signaturecommit-guard --require-trailer "Closes,Reviewed-by"
+
+ The signature check verifies commits without requiring a
+ pre-configured local keyring:
+
github.com/{username}.gpg and github.com/{username}.keys.allowed_signers file.
+ 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
+
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