diff --git a/.github/renovate.json5 b/.github/renovate.json5 new file mode 100644 index 0000000..3807b43 --- /dev/null +++ b/.github/renovate.json5 @@ -0,0 +1,21 @@ +{ + $schema: "https://docs.renovatebot.com/renovate-schema.json", + enabledManagers: ["github-actions"], + // PR titles: `deps(): ...` + semanticCommits: "enabled", + semanticCommitType: "deps", + packageRules: [ + { + matchManagers: ["github-actions"], + schedule: ["on monday"], + // skip fresh releases: may be unstable or compromised + minimumReleaseAge: "14 days", + // semver-coerced so short tags (v3, v1.7) still parse; pin to full SHA, tag as trailing comment + versioning: "semver-coerced", + pinDigests: true, + separateMajorMinor: false, + semanticCommitScope: "{{depName}}", + commitMessageTopic: "{{depName}}", + }, + ], +} diff --git a/.github/workflows/self-verify.yml b/.github/workflows/self-verify.yml new file mode 100644 index 0000000..9371a43 --- /dev/null +++ b/.github/workflows/self-verify.yml @@ -0,0 +1,13 @@ +name: self-verify ๐Ÿชž + +on: + pull_request: + +permissions: {} + +jobs: + verify: + permissions: + contents: read + pull-requests: read + uses: ./.github/workflows/verify.yml diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..a5dcba8 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,29 @@ +name: verify commit signatures ๐Ÿ•ต๐Ÿปโ€โ™‚๏ธ + +on: + workflow_call: {} + +permissions: {} + +jobs: + verify-ssh-signatures: + timeout-minutes: 10 + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + # pins script + allowed_signers to the workflow revision the caller pinned + - name: ๐Ÿ“ฅ Check out commit-verifier + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + persist-credentials: false + + - name: ๐Ÿ”‘ Verify SSH commit signatures + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + run: ./verify_commits.sh "$PR" diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 0000000..03eaa8b --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,74 @@ +name: zizmor + +on: + pull_request: + paths: + - ".github/**" + push: + branches: [main] + paths: + - ".github/**" + +permissions: {} + +jobs: + zizmor: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: write # sticky summary comment + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + # fails on findings; annotations cap at 10, the PR comment carries the full list + # advanced-security off: no GHAS on this repo, SARIF upload would 403 + - name: Run zizmor + uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 + with: + version: "1.28.0" + advanced-security: false + annotations: true + + # re-run via docker for JSON output; the action only emits SARIF/annotations + - name: Generate summary + if: always() && github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} # online audits, same as the action run + run: | + rc=0 + docker run --rm -e GH_TOKEN -v "$PWD:/src" ghcr.io/zizmorcore/zizmor:1.28.0@sha256:8e6b3e4fb74d1aa5d23e83ea369f386c66eced0d1fb944d32cd8b2aac100b00d \ + --format=json /src > findings.json || rc=$? + # exit codes 10-14 = findings; anything else is a real error + if [ "$rc" -ne 0 ] && { [ "$rc" -lt 10 ] || [ "$rc" -gt 14 ]; }; then exit "$rc"; fi + + cat > summary.jq <<'JQ' + def rank: {"High": 0, "Medium": 1, "Low": 2, "Informational": 3, "Unknown": 4}[.] // 5; + def loc: .locations[0] as $l + | (.locations[0].symbolic.key.Local.given_path // "?" | ltrimstr("/src/")) + + ":" + (($l.concrete.location.start_point.row + 1) | tostring); + if length == 0 then + "### ๐ŸŒˆ zizmor\n\nโœ… No findings in GitHub Actions workflows." + else + ( group_by(.determinations.severity) | map({(.[0].determinations.severity): length}) | add ) as $counts + | "### ๐ŸŒˆ zizmor: \(length) finding\(if length == 1 then "" else "s" end)\n\n" + + ( ["High", "Medium", "Low", "Informational", "Unknown"] + | map(select($counts[.] != null) | "**\($counts[.])** \(.)") | join(" ยท ") ) + "\n\n" + + "
Full results\n\n" + + "| Severity | Confidence | Rule | Location | Description |\n|---|---|---|---|---|\n" + + ( sort_by([(.determinations.severity | rank), loc]) + | map("| \(.determinations.severity) | \(.determinations.confidence) | [\(.ident)](\(.url)) | `\(loc)` | \(.desc) |") + | join("\n") ) + + "\n\n
" + end + JQ + jq -r -f summary.jq findings.json > comment.md + + - name: Post PR comment + if: always() && github.event_name == 'pull_request' + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 + with: + header: zizmor + path: comment.md diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..bbdecb3 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @cowprotocol/devops diff --git a/README.md b/README.md index 7ecc589..2acbe49 100644 --- a/README.md +++ b/README.md @@ -1 +1,70 @@ -# commit-verifier \ No newline at end of file +# commit-verifier + +Canonical home of the `allowed_signers` registry and the reusable workflow that +checks every human-authored commit in a PR was SSH-signed by a hardware-backed +(`sk-`, FIDO2) key registered to the author's GitHub account and enrolled in +`allowed_signers`. + +## Using it from a repo + +```yaml +jobs: + verify-commit-signatures: + uses: cowprotocol/commit-verifier/.github/workflows/verify.yml@ + permissions: + contents: read + pull-requests: read +``` + +Pin to a full commit SHA, not a branch or tag. The workflow checks out this +repo at that same SHA, so the script and `allowed_signers` are pinned too. +Never pass `secrets: inherit`; the workflow only needs the automatic +`GITHUB_TOKEN`. + +## Enrollment (once per person) + +1. **Create a hardware key** (FIDO2 authenticator, e.g. YubiKey; confirm it's + genuine first: ). It requires a touch to + sign, which the check enforces. + + ```bash + ssh-keygen -t ed25519-sk -C "$(whoami) commit signing" -f ~/.ssh/id_ed25519_sk_git + ``` + +2. **Register it on GitHub** โ†’ _Settings โ†’ SSH and GPG keys โ†’ New SSH key_, + type **Signing Key**. Paste the contents of `~/.ssh/id_ed25519_sk_git.pub`. + +3. **Tell git to sign with it:** + + ```bash + git config --global gpg.format ssh + git config --global user.signingkey ~/.ssh/id_ed25519_sk_git.pub + git config --global commit.gpgsign true + ``` + +4. **Add your key to `allowed_signers`** here: one line, ` +`, and open a PR. A reviewer merging it enrolls you. + +The email you commit with must also be on your GitHub account; the check +resolves your account from it. + +## How the check works + +Runs on `pull_request`. Each commit must pass, in order: + +1. the author's GitHub account has an `sk-` signing key; +2. the signature is valid and made by one of those keys; +3. that key is enrolled in `allowed_signers`; +4. the signature carries the user-presence flag: the key was **touched**. + +Failing any step is a hard CI failure (aka red X check failure), unless the +commit is from an allow-listed automated account (they have no hardware key); +those warn instead. An automated account is allowed through only when the +**signature** proves. + +## Run locally + +```bash +GH_TOKEN=$(gh auth token) GITHUB_REPOSITORY=owner/repo \ + ./verify_commits.sh +``` diff --git a/allowed_signers b/allowed_signers new file mode 100644 index 0000000..72d044f --- /dev/null +++ b/allowed_signers @@ -0,0 +1,5 @@ +# Enrolled hardware (sk-) commit-signing keys โ€” the trusted registry. +assem@cow.fi sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIPq6XpBwnJEBmey+fnfxR6Z1RXR084NfIy48v+J1DXk+AAAABHNzaDo= + +# cow-protocol CI bot โ€” non-sk software key +gh_111@cow.fi ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFHJQvdfBMMNNDCsq8LJS2/yCxYKbFiw6Jkcjym8CvZw diff --git a/verify_commits.sh b/verify_commits.sh new file mode 100755 index 0000000..0686fc0 --- /dev/null +++ b/verify_commits.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# See README.md for what this checks, how it works, and its limits. +# Usage: GH_TOKEN=... GITHUB_REPOSITORY=owner/repo verify_commits.sh +set -euo pipefail + +: "${GH_TOKEN:?GH_TOKEN required}" +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY required}" +PR_NUMBER="${1:?usage: $0 }" + +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +WEBFLOW_EMAIL="noreply@github.com" +WEBFLOW_BOT_LOGINS=("renovate[bot]" "github-actions[bot]") +ALLOWED_AUTOMATED_LOGINS=("cow-protocol") + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ALLOWED_SIGNERS_FILE="${ALLOWED_SIGNERS_FILE:-$SCRIPT_DIR/allowed_signers}" + +log() { printf '[verify-commits] %s\n' "$*" >&2; } + +is_allowed_automated_account() { + local author_login="$1" author_email="$2" signature_file="$3" payload_file="$4" + printf '%s\n' "${ALLOWED_AUTOMATED_LOGINS[@]}" | grep -qxF "$author_login" || return 1 + in_allowed_signers_registry "$author_email" "$signature_file" "$payload_file" +} + +is_verified_webflow() { + local author_login="$1" committer_email="$2" verified="$3" + [[ "$committer_email" == "$WEBFLOW_EMAIL" && "$verified" == "true" ]] || return 1 + printf '%s\n' "${WEBFLOW_BOT_LOGINS[@]}" | grep -qxF "$author_login" +} + +fingerprint_of_key() { + local key_line="$1" + printf '%s\n' "$key_line" | ssh-keygen -lf /dev/stdin 2>/dev/null | awk '{ print $2 }' +} + +in_allowed_signers_registry() { + local author_email="$1" signature_file="$2" payload_file="$3" + ssh-keygen -Y verify -f "$ALLOWED_SIGNERS_FILE" -I "$author_email" \ + -n git -s "$signature_file" <"$payload_file" >/dev/null 2>&1 +} + +list_author_sk_fingerprints() { + local login="$1" + local cache="$WORKDIR/fingerprints-$login" + if [[ -f "$cache" ]]; then + cat "$cache" + return 0 + fi + + local keys_file="$WORKDIR/keys-$login" + if [[ ! -f "$keys_file" ]]; then + if ! gh api --paginate "users/$login/ssh_signing_keys" --jq '.[].key' >"$keys_file" 2>/dev/null; then + return 1 + fi + fi + + : >"$cache" + local key_line key_algorithm fingerprint + while IFS= read -r key_line; do + [[ -z "$key_line" ]] && continue + key_algorithm="$(printf '%s\n' "$key_line" | awk '{ print $1 }')" + [[ "$key_algorithm" != sk-* ]] && continue + fingerprint="$(fingerprint_of_key "$key_line" || true)" + if [[ -n "$fingerprint" ]]; then + printf '%s\n' "$fingerprint" >>"$cache" + fi + done <"$keys_file" + + cat "$cache" +} + +verified_signer_fingerprint() { + local signature_file="$1" + local payload_file="$2" + if [[ ! -s "$signature_file" ]]; then + return 1 + fi + + local check_output + if ! check_output="$(ssh-keygen -Y check-novalidate -n git -s "$signature_file" <"$payload_file" 2>&1)"; then + return 1 + fi + + local fingerprint + fingerprint="$(printf '%s\n' "$check_output" | grep -oE 'SHA256:[A-Za-z0-9+/]+' | head -n1)" + if [[ -z "$fingerprint" ]]; then + return 1 + fi + + printf '%s\n' "$fingerprint" +} + +signature_has_touch() { + local signature_file="$1" flags + flags="$(grep -v '^-----' "$signature_file" | base64 -d 2>/dev/null | tail -c 5 | head -c 1 | od -An -tu1 | tr -d ' ' || true)" + [[ -n "$flags" && $((flags & 1)) -eq 1 ]] +} + +REASON="" +check_commit_sk_signed() { + local author_login="$1" + local author_email="$2" + local signature_file="$3" + local payload_file="$4" + REASON="" + + if [[ -z "$author_login" ]]; then + REASON="author $author_email is not linked to any GitHub account" + return 1 + fi + + local author_fingerprints + if ! author_fingerprints="$(list_author_sk_fingerprints "$author_login")"; then + REASON="could not fetch @$author_login's signing keys from GitHub" + return 1 + fi + if [[ -z "$author_fingerprints" ]]; then + REASON="@$author_login has no sk- (hardware-backed) signing key on GitHub" + return 1 + fi + + local signer_fingerprint + signer_fingerprint="$(verified_signer_fingerprint "$signature_file" "$payload_file" || true)" + if [[ -z "$signer_fingerprint" ]]; then + REASON="commit is unsigned or its signature is not a valid SSH signature" + return 1 + fi + + if ! printf '%s\n' "$author_fingerprints" | grep -qxF "$signer_fingerprint"; then + REASON="not signed by an sk- (hardware-backed) key belonging to @$author_login" + return 1 + fi + + if ! in_allowed_signers_registry "$author_email" "$signature_file" "$payload_file"; then + REASON="signing key is not enrolled in the allowed_signers registry for $author_email" + return 1 + fi + + if ! signature_has_touch "$signature_file"; then + REASON="commit was signed without a touch (user-presence flag not set)" + return 1 + fi + + return 0 +} + +COMMITS_FILE="$WORKDIR/commits.tsv" +gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/commits" \ + --jq '.[] | [ + .sha, + (.author.login // ""), + .commit.author.email, + .commit.committer.email, + (.commit.verification.verified | tostring), + (.commit.verification.signature // "" | @base64), + (.commit.verification.payload // "" | @base64) + ] | @tsv' >"$COMMITS_FILE" + + +commit_count="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.commits')" +log "PR #$PR_NUMBER: verifying $commit_count commit(s)" +if [[ "$commit_count" -gt 250 ]]; then # the GitHub API limits to 250 + echo "::error::PR has $commit_count commits; only the first 250 are returned by the API and can be verified โ€” split the PR or squash commits" + exit 1 +fi + +[[ -s "$ALLOWED_SIGNERS_FILE" ]] || + log "warning: allowed_signers registry missing or empty ($ALLOWED_SIGNERS_FILE) โ€” every human commit will fail the registry check" + +ok=0 +warned=0 +failed=0 +while IFS=$'\t' read -r sha author_login author_email committer_email verified signature_b64 payload_b64; do + [[ -z "$sha" ]] && continue + short_sha="${sha:0:9}" + + base64 -d <<<"$signature_b64" >"$WORKDIR/sig" 2>/dev/null || true + base64 -d <<<"$payload_b64" >"$WORKDIR/payload" 2>/dev/null || true + + exempt_reason="" + if is_verified_webflow "$author_login" "$committer_email" "$verified"; then + exempt_reason="web-flow bot @$author_login" + elif is_allowed_automated_account "$author_login" "$author_email" "$WORKDIR/sig" "$WORKDIR/payload"; then + exempt_reason="allowed automated account @$author_login" + fi + + if check_commit_sk_signed "$author_login" "$author_email" "$WORKDIR/sig" "$WORKDIR/payload"; then + echo "OK $short_sha @$author_login" + ok=$((ok + 1)) + elif [[ -n "$exempt_reason" ]]; then + echo "::warning::$short_sha: $REASON โ€” not an sk- key, allowed ($exempt_reason)" + warned=$((warned + 1)) + else + echo "::error::$short_sha: $REASON" + failed=$((failed + 1)) + fi +done <"$COMMITS_FILE" + +log "done: $ok ok, $warned warning(s), $failed failure(s)" +if [[ "$failed" -gt 0 ]]; then + exit 1 +fi