Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ <h3>Checks</h3>
</tr>
<tr>
<td><code>signature</code></td>
<td>GPG or SSH signature is valid</td>
<td>GPG or SSH signature is valid — verified via GitHub public key lookup</td>
</tr>
</tbody>
</table>
Expand Down Expand Up @@ -426,6 +426,25 @@ <h3>Required trailers</h3>
</p>
<pre><code class="language-bash">commit-guard --require-trailer "Closes,Reviewed-by"</code></pre>

<h3>Signature verification</h3>
<p>
The <code>signature</code> check verifies commits without requiring a
pre-configured local keyring:
</p>
<ol>
<li>Look up the commit author's email via the GitHub search API to find their username.</li>
<li>Fetch their public keys from <code>github.com/{username}.gpg</code> and <code>github.com/{username}.keys</code>.</li>
<li>Try GPG verification using a temporary keyring.</li>
<li>Try SSH verification using a temporary <code>allowed_signers</code> file.</li>
<li>Pass if any key verifies; fail if none do.</li>
</ol>
<p>
If the author's email is not found on GitHub, or the API is
unreachable, the check fails with a clear error. Disable the
<code>signature</code> check if GitHub API access is unavailable:
</p>
<pre><code class="language-bash">commit-guard --disable signature</code></pre>

<h3>Range options</h3>
<p>
When using <code>--range</code>, merge commits are excluded by
Expand Down
120 changes: 108 additions & 12 deletions src/git_commit_guard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading