Safeguard
Supply Chain Security•

How to Verify GitHub Artifact Attestations Without Trusting Lookalike Signers

gh attestation verify is only as strict as the identity you pin. After CVE-2026-64655: the flags to use, fields to trust, and an exact-match ID policy.

Safeguard Security Team
Security Research
8 min read

gh attestation verify checks that an artifact was signed, but you decide whose signature counts. On 15 October, GitHub's advisory database added CVE-2026-64655 (the CVE record itself was published on 6 August): before GitHub CLI 2.97.0, the --signer-repo and --signer-workflow flags were treated as regular expressions. A . in a trusted name matched any character, so a lookalike repository could pass. The fix shipped on 31 July. The lesson applies to any attestation policy: pin the signer with exact values, and preferably with GitHub's numeric repository IDs, which a lookalike can't copy.

This guide is for teams that verify GitHub artifact attestations in CI, at deploy time or in an admission policy. It explains what the verification proves, which flags to use, which output fields you can trust, and how to add an exact-match policy on top of gh. Both scripts below use only the Python standard library.

The short answer

Run GitHub CLI 2.97.0 or later. Verify with --repo owner/name rather than --owner alone. If the attestation comes from a reusable workflow, also pin --signer-workflow to the full workflow path, or --cert-identity to the exact certificate identity. Add --source-ref, --deny-self-hosted-runners and the --predicate-type you expect. Then check the JSON output against an exact policy that includes the source repository's numeric ID. Base decisions only on signature.certificate and verifiedTimestamps, never on the predicate.

What verification proves, and what it doesn't

An attestation is a signed statement about an artifact, usually SLSA provenance saying which workflow built it. When GitHub Actions creates one, Sigstore's Fulcio issues a short-lived certificate based on the workflow's OIDC token. The certificate records where the build came from: the source repository, its owner, the ref, the commit, the workflow file that signed, and whether the runner was GitHub-hosted.

gh attestation verify checks the signature, the transparency log or timestamp, and that the artifact's digest matches the statement's subject. Then it checks the identity against whatever you pass. The manual states the trade-off plainly: "The more precisely you specify the identity, the more control you will have over the security guarantees". With only --owner acme, any workflow in any repository under acme can produce an attestation that passes.

The manual also says which output fields an attacker can't influence: signature.certificate, filled from GitHub's OIDC token, and verifiedTimestamps, from the log or timestamp authority. The statement.predicate is written by the workflow. If someone gains control of the build, they control what it says.

The regex bug in one example

The CLI built a pattern from --signer-repo and --signer-workflow and matched it against the certificate's Subject Alternative Name without escaping. GitHub allows . in repository names, and . in a regular expression matches any single character. A quick reproduction of the mistake:

import re

trusted = "acme/release.tools"   # repository names may contain "."
for candidate in ("acme/release.tools", "acme/release-tools", "acme/releaseXtools"):
    san = f"https://github.com/{candidate}/.github/workflows/sign.yml@refs/heads/main"
    loose = re.match(rf"^https://github\.com/{trusted}/", san) is not None
    exact = re.match(rf"^https://github\.com/{re.escape(trusted)}/", san) is not None
    print(f"{candidate:22} unescaped: {loose!s:5}  escaped: {exact}")
acme/release.tools     unescaped: True   escaped: True
acme/release-tools     unescaped: True   escaped: False
acme/releaseXtools     unescaped: True   escaped: False

The advisory rates it low, CVSS 4.0 score 2.1. An attacker needs a plausible lookalike name and real Sigstore attestations from it. The --repo and --owner flags always used exact comparison and weren't affected. Version 2.97.0 escapes the values before matching. The fix was released on 31 July, and the advisory entered GitHub's reviewed database on 15 October.

The flags worth setting

FlagWhat it pinsUse it when
--repo owner/nameThe source repository, compared exactlyAlways, unless you really accept any repository in the org
--signer-workflow host/owner/repo/path/to/workflow.ymlThe workflow that signedThe attestation comes from a reusable workflow (a "trusted builder")
--cert-identity <SAN>The full certificate identity, compared exactly, including the refYou want one exact signer at one ref
--source-ref refs/tags/...The git ref that was builtReleases should only come from tags or a protected branch
--signer-digest / --source-digestThe commit of the signer workflow or sourceYou pin a specific builder version
--deny-self-hosted-runnersThe runner environmentSelf-hosted runners aren't part of your trust boundary
--predicate-typeThe kind of claimYou verify something other than the default SLSA provenance v1

If your build signs through a reusable workflow, the manual says that workflow "is the signer whose identity needs to be validated". So --repo alone isn't enough there. Pair it with --signer-workflow on 2.97.0 or later, or with --cert-identity.

A typical release check:

gh attestation verify app.tar.gz \
  --repo acme/app \
  --signer-workflow github.com/acme/release.tools/.github/workflows/sign.yml \
  --source-ref refs/tags/v1.4.0 \
  --deny-self-hosted-runners \
  --format json > verify-output.json

Pin IDs, not just names

Names can change hands. A repository can be renamed, deleted and recreated, or transferred, and a new one can be made with a confusable name. Fulcio certificates carry immutable identifiers alongside the names: 1.3.6.1.4.1.57264.1.15, Source Repository Identifier, and .1.17, Source Repository Owner Identifier. For GitHub these are the numeric repository_id and owner ID. Fulcio's documentation describes the first as an "Immutable identifier for the source repository".

gh has no flag for them. They are in the JSON output, under the field names sigstore-go uses: sourceRepositoryIdentifier and sourceRepositoryOwnerIdentifier. Look up the values once with gh api repos/acme/app --jq .id and gh api orgs/acme --jq .id, and store them in your policy.

Add an exact-match policy

This script reads gh attestation verify --format json from standard input and applies a policy file. Every check is an exact string comparison against certificate fields. It passes only if at least one attestation meets every rule.

#!/usr/bin/env python3
"""Enforce an exact-match policy on `gh attestation verify --format json` output.

  gh attestation verify app.tar.gz --repo acme/app --format json \
    | python3 attestation_policy.py policy.json

gh has already checked the signature, transparency log and digest. This script
checks who signed, using only the certificate fields GitHub fills in from the
OIDC token, by exact string comparison. It pins the numeric repository and owner
IDs, which a renamed, recreated or lookalike repository can't reproduce.
Exits 1 unless at least one attestation passes every rule. Standard library only.

policy.json keys (all optional except one identity rule):
  source_repository_identifier, source_repository_owner_identifier,
  build_signer_uri, allowed_source_refs, runner_environment, predicate_type
"""
import json, sys

FIELDS = {  # policy key -> certificate summary field (sigstore-go JSON names)
    "source_repository_identifier": "sourceRepositoryIdentifier",
    "source_repository_owner_identifier": "sourceRepositoryOwnerIdentifier",
    "build_signer_uri": "buildSignerURI",
    "runner_environment": "runnerEnvironment",
}


def check(entry, policy):
    result = entry["verificationResult"]
    cert = result["signature"]["certificate"]
    problems = []
    for key, field in FIELDS.items():
        if key in policy and cert.get(field) != policy[key]:
            problems.append(f"{field} is {cert.get(field)!r}, want {policy[key]!r}")
    refs = policy.get("allowed_source_refs")
    if refs and cert.get("sourceRepositoryRef") not in refs:
        problems.append(f"sourceRepositoryRef {cert.get('sourceRepositoryRef')!r} not in {refs}")
    want_type = policy.get("predicate_type")
    if want_type and result["statement"].get("predicateType") != want_type:
        problems.append(f"predicateType is {result['statement'].get('predicateType')!r}")
    return cert, problems


def main():
    if len(sys.argv) != 2:
        sys.exit(__doc__)
    policy = json.load(open(sys.argv[1]))
    if not any(k in policy for k in ("source_repository_identifier", "build_signer_uri")):
        sys.exit("policy must pin source_repository_identifier or build_signer_uri")
    entries = json.load(sys.stdin)
    passed = 0
    for i, entry in enumerate(entries, 1):
        cert, problems = check(entry, policy)
        signer = cert.get("buildSignerURI", "?")
        if problems:
            print(f"attestation {i}: REJECT {signer}")
            for p in problems:
                print(f"    {p}")
        else:
            passed += 1
            print(f"attestation {i}: ok {signer} (repo id {cert.get('sourceRepositoryIdentifier')})")
    print(f"{passed} of {len(entries)} attestation(s) meet the policy")
    sys.exit(0 if passed else 1)


if __name__ == "__main__":
    main()

The policy for the release above:

{
 "source_repository_identifier": "812345678",
 "source_repository_owner_identifier": "1234567",
 "build_signer_uri": "https://github.com/acme/release.tools/.github/workflows/sign.yml@refs/heads/main",
 "allowed_source_refs": [
  "refs/tags/v1.4.0",
  "refs/heads/main"
 ],
 "runner_environment": "github-hosted",
 "predicate_type": "https://slsa.dev/provenance/v1"
}

We ran it on 16 October against a fixture of four attestations: the real release, one signed by the lookalike acme/release-tools, one from a self-hosted runner, and one built from a feature branch:

$ python3 attestation_policy.py policy.json < verify-output.json
attestation 1: ok https://github.com/acme/release.tools/.github/workflows/sign.yml@refs/heads/main (repo id 812345678)
attestation 2: REJECT https://github.com/acme/release-tools/.github/workflows/sign.yml@refs/heads/main
    sourceRepositoryIdentifier is '903311207', want '812345678'
    buildSignerURI is 'https://github.com/acme/release-tools/.github/workflows/sign.yml@refs/heads/main', want 'https://github.com/acme/release.tools/.github/workflows/sign.yml@refs/heads/main'
attestation 3: REJECT https://github.com/acme/release.tools/.github/workflows/sign.yml@refs/heads/main
    runnerEnvironment is 'self-hosted', want 'github-hosted'
attestation 4: REJECT https://github.com/acme/release.tools/.github/workflows/sign.yml@refs/heads/main
    sourceRepositoryRef 'refs/heads/feature/test' not in ['refs/tags/v1.4.0', 'refs/heads/main']
1 of 4 attestation(s) meet the policy
exit 0

Only the real release passes. The lookalike fails twice, on the signer URI and on the repository ID. A deploy gate can fail on a non-zero exit.

What to do

  1. Upgrade gh to 2.97.0 or later on CI runners and in any admission or deploy tooling that shells out to it. Check images that bundle their own copy.
  2. Replace --owner-only checks with --repo, plus --signer-workflow or --cert-identity for reusable workflows.
  3. Pin the ref and runner type with --source-ref and --deny-self-hosted-runners.
  4. Pin numeric IDs in a policy on the JSON output.
  5. Ignore the predicate for trust decisions. Use it for information only, unless the build ran in a trusted builder whose inputs the caller can't influence.

The fair view

The CLI bug itself is minor. It needed a lookalike name, real attestations from that name and a policy that relied on the two signer flags. Plenty of teams verifying attestations used --repo and were never exposed. GitHub fixed it, published the details and credits the analyst who reported it, helixplant.

The broader problem is policies that are looser than they look. A check that passes for "anything signed in our org" or "anything whose name matches this pattern" is a check an attacker can satisfy without breaking any cryptography. Our post on SLSA v1 provenance attestations covers what the provenance itself says. The point here is narrower: verification is only as strict as the identity you ask it to check.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.