Cybersecurity & TrustApril 6, 2026

Building A Go Module Source Attestation Check Using Sigstore And Rekor

V

Written by

Vera Crypt

I stumbled into a weird corner of software supply chain security while debugging a “perfectly signed” release that still felt untrustworthy: the signature covered the tagged artifact, but the underlying Go module source code could be swapped during dependency fetch (or quietly substituted via a compromised registry/proxy). That’s when I decided to focus on something very specific: verifying that the Go module content referenced by go.mod matches a Sigstore/Rekor transparency log entry—before I let my build proceed.

What I built is a small, reproducible verifier that:

  1. Reads the module versions from go.mod
  2. Downloads the module source zip exactly as GOMODCACHE would
  3. Computes a deterministic digest of the module content
  4. Checks that digest against a Sigstore transparency log entry recorded in Rekor
  5. Fails the build if any module doesn’t match what’s logged

Below is the full walkthrough with working code.


The niche problem: “Signed tag, mismatched module content”

Go’s dependency management often gives a false sense of safety because teams check things like “the release is signed” or “the dependency is from a trusted host.” But in practice, what you really need is to ensure that the exact source content your build consumes is the one that was previously attested.

To make this concrete, I wanted to answer:

“Does the content hash of example.com/some/module@v1.2.3 equal the content hash that was attested and logged in Rekor?”


Terminology I used while building

  • Go module: A dependency defined in go.mod with a module path and semantic version (like v1.2.3).
  • Sigstore: A system that signs and verifies artifacts with signatures tied to identities, designed for supply chain workflows.
  • Rekor (Transparency log): An append-only log for security events; it lets you verify that an attestation existed at a certain time.
  • Content digest: A cryptographic hash (I use SHA-256) computed from downloaded module source bytes.

Overview of the verification flow

When the verifier runs:

  1. It parses go.mod and extracts required modules.
  2. For each module, it downloads its source zip using GOPROXY behavior (so it mirrors typical builds).
  3. It hashes the bytes of that zip (content digest).
  4. It queries Rekor for a matching log entry (using the digest).
  5. It only allows the build to proceed if all required modules match.

Step 1: A Go module verifier that queries Rekor

What the code does (high-level)

  • parseGoMod: reads go.mod and extracts require entries.
  • downloadModuleZip: uses go mod download -json to fetch the module and locate the zip file path.
  • sha256File: hashes the downloaded zip content.
  • rekorSearchByDigest: queries Rekor’s search endpoint using a digest-based query.
  • main: ties it together and fails fast.

Working code: cmd/verify-modules/main.go

package main import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "strings" "time" "golang.org/x/mod/modfile" ) type ModuleReq struct { Path string Version string } type GoModDownloadJSON struct { Path string Version string Info string GoMod string InfoJSON string // For module zip: use the "Dir" and then reconstruct? In practice, // go mod download provides "Zip" in recent Go versions when using -json. // We'll rely on "Info" being non-empty and also attempt to locate zip. Dir string // Zip is not always present depending on Go version; we will fall back. Zip string `json:"Zip"` } func parseGoMod(goModPath string) ([]ModuleReq, error) { b, err := os.ReadFile(goModPath) if err != nil { return nil, fmt.Errorf("read go.mod: %w", err) } f, err := modfile.Parse("go.mod", b, nil) if err != nil { return nil, fmt.Errorf("parse go.mod: %w", err) } var reqs []ModuleReq for _, r := range f.Require { if r.Indirect { // I keep it simple: verify both direct and indirect. // In a stricter setup you can limit to direct requirements only. } reqs = append(reqs, ModuleReq{Path: r.Mod.Path, Version: r.Mod.Version}) } return reqs, nil } func downloadModuleZip(modulePath, version string) (string, error) { // We mimic the same behavior as "go build" would by using go toolchain. cmd := exec.Command("go", "mod", "download", "-json", fmt.Sprintf("%s@%s", modulePath, version)) cmd.Env = append(os.Environ(), "GOMODCACHE="+defaultGoModCache()) var out bytes.Buffer cmd.Stdout = &out cmd.Stderr = &out if err := cmd.Run(); err != nil { return "", fmt.Errorf("go mod download failed for %s@%s: %w\n%s", modulePath, version, err, out.String()) } var info GoModDownloadJSON if err := json.Unmarshal(out.Bytes(), &info); err != nil { return "", fmt.Errorf("parse go mod download json: %w", err) } // Prefer "Zip" if present. if info.Zip != "" { return info.Zip, nil } // Fallback: locate the downloaded zip in module cache. // The module download cache layout is not officially stable, but // for practical builds this fallback works. // We locate the directory and then re-zip it? That breaks the “exact bytes” // matching used by attestation. So for fidelity, we try to find the zip: // // Common layout: // $GOMODCACHE/cache/download/<host>/<path>/@v/<version>.zip // We reconstruct this using GOPATH-ish behavior via `GOMODCACHE`. cacheDir := filepath.Join(defaultGoModCache(), "cache", "download") host, modPathEscaped := splitHostAndPath(modulePath) zipName := fmt.Sprintf("%s.zip", strings.TrimPrefix(version, "v")) // commonly versions are stored with "vX.Y.Z.zip" // But module cache uses the full version tag in file names: v1.2.3.zip zipName = fmt.Sprintf("%s.zip", version) candidate := filepath.Join(cacheDir, host, modPathEscaped, "@v", zipName) if _, err := os.Stat(candidate); err == nil { return candidate, nil } // As a last resort: fail, because re-zipping would make digests differ. return "", errors.New("could not locate module zip in mod cache; Go version/cache layout mismatch") } func defaultGoModCache() string { // If GOMODCACHE is set, honor it; otherwise use GOPATH default. if v := os.Getenv("GOMODCACHE"); v != "" { return v } gopath := os.Getenv("GOPATH") if gopath == "" { // Default GOPATH is typically $HOME/go home, _ := os.UserHomeDir() gopath = filepath.Join(home, "go") } return filepath.Join(gopath, "pkg", "mod") } // splitHostAndPath converts "example.com/foo/bar" into: // host="example.com" // modPathEscaped="foo/bar" but with cache escaping for "!" rules. func splitHostAndPath(modulePath string) (string, string) { parts := strings.Split(modulePath, "/") host := parts[0] pathParts := parts[1:] // Go module cache uses escape rules: // - "!" prefix for uppercase letters, and "@" etc. handled. // For simplicity and because this is niche, I rely on Go's own escape behavior // by leaving the path as-is for lowercase-only module paths. // For production, wire in golang.org/x/mod/module.EscapePath. escaped := strings.Join(pathParts, "/") return host, escaped } func sha256File(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", fmt.Errorf("open zip for hashing: %w", err) } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { return "", fmt.Errorf("hash zip: %w", err) } return hex.EncodeToString(h.Sum(nil)), nil } func rekorSearchByDigest(rekorBaseURL, sha256Hex string) ([]map[string]any, error) { // Rekor search API commonly supports a query format like: // /api/v1/search?hash=... // The exact parameterization depends on Rekor version and entry type. // // I used a minimal approach: // - query by "hash" using Rekor’s generic endpoint (commonly supported) // - if your Rekor setup differs, adapt this to your entry type/search schema. searchURL := strings.TrimRight(rekorBaseURL, "/") + "/api/v1/search" // Many Rekor deployments accept "hash" or "hashes" depending on plugins. // We'll try "hash" with a JSON body fallback is not available; use query params. req, err := http.NewRequest("GET", searchURL, nil) if err != nil { return nil, err } q := req.URL.Query() q.Set("hash", sha256Hex) req.URL.RawQuery = q.Encode() client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("rekor search request failed: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("rekor search returned %d: %s", resp.StatusCode, string(body)) } // Response format varies; handle the common envelope: // { "entries": [ ... ] } var parsed map[string]any if err := json.Unmarshal(body, &parsed); err != nil { return nil, fmt.Errorf("parse rekor response: %w", err) } entriesAny, ok := parsed["entries"] if !ok { // Some versions return "data" entriesAny = parsed["data"] } if entriesAny == nil { return nil, errors.New("rekor response missing entries/data") } entriesSlice, ok := entriesAny.([]any) if !ok { return nil, errors.New("rekor entries has unexpected type") } var out []map[string]any for _, e := range entriesSlice { if m, ok := e.(map[string]any); ok { out = append(out, m) } } return out, nil } func main() { if len(os.Args) != 3 { fmt.Fprintf(os.Stderr, "Usage: %s /path/to/go.mod https://rekor.example.com\n", os.Args[0]) os.Exit(2) } goModPath := os.Args[1] rekorBaseURL := os.Args[2] // Keep behavior deterministic across environments. _ = os.Setenv("GOSUMDB", "off") // optional: avoid extra network during module download; Rekor check is the trust anchor. reqs, err := parseGoMod(goModPath) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } if len(reqs) == 0 { fmt.Fprintln(os.Stderr, "No dependencies found in go.mod") os.Exit(1) } fmt.Printf("Found %d module requirements in %s\n", len(reqs), goModPath) missing := false for _, r := range reqs { zipPath, err := downloadModuleZip(r.Path, r.Version) if err != nil { fmt.Fprintf(os.Stderr, "FAIL: download zip %s@%s: %v\n", r.Path, r.Version, err) missing = true continue } digest, err := sha256File(zipPath) if err != nil { fmt.Fprintf(os.Stderr, "FAIL: hash zip %s@%s: %v\n", r.Path, r.Version, err) missing = true continue } entries, err := rekorSearchByDigest(rekorBaseURL, digest) if err != nil { fmt.Fprintf(os.Stderr, "FAIL: rekor lookup for %s@%s digest %s: %v\n", r.Path, r.Version, digest, err) missing = true continue } if len(entries) == 0 { fmt.Fprintf(os.Stderr, "FAIL: no Rekor entries for %s@%s digest %s\n", r.Path, r.Version, digest) missing = true continue } // If Rekor contains multiple entries, in a real system you’d additionally validate: // - the attestation subject format (module path/version) // - signer identity / policy // This sample focuses on digest presence. fmt.Printf("OK: %s@%s digest %s matched %d Rekor entries\n", r.Path, r.Version, digest, len(entries)) // Optional: short sleep to be friendly to Rekor under heavy CI loads time.Sleep(50 * time.Millisecond) } if missing { os.Exit(1) } fmt.Println("All module sources matched Rekor digests. Build can proceed.") }

Build it

go build -o verify-modules ./cmd/verify-modules

Step 2: Run the verifier

Assuming your project has a go.mod:

./verify-modules ./go.mod https://rekor.example.com

What you should see:

  • For each module: it prints the SHA-256 digest
  • Then it prints whether Rekor returned at least one matching entry
  • If any module has no match, the program exits with code 1

Step 3: How to create the Rekor entries (attestation side)

Verification only works if the log is populated with the same digest you compute from module zips.

In Sigstore/Rekor workflows, people commonly do this by creating an attestation that includes the digest. The exact attestation type can vary (and Rekor entry format differs by plugin), but the principle is:

  • Compute the same SHA-256 over the module zip bytes
  • Sign or produce an attestation payload
  • Submit it to Rekor
  • Store the digest in a field searchable by your verifier

Because Rekor entry types are deployment-specific, I’m going to show the “attestation payload shape” at the level your verifier needs: entries must be retrievable by the digest.

For a working end-to-end demo, the easiest path is to ensure your Rekor setup indexes that digest into the hash field (or whatever your verifier queries).


Making the digest match what Rekor expects

This is the part that surprised me the most when I first tried.

What I learned

  • Hashing module directory contents does not match hashing module zip bytes.
  • Even tiny differences (file ordering, metadata, line endings) can change hashes.
  • Your attestation must hash the same bytes the verifier hashes.

In my implementation, I hash:

  • the bytes of the module zip file located under the Go module cache

That means the attestation producer must also hash the zip bytes for the same version.


What happens when you run it in CI

Here’s what the typical CI pipeline behavior looks like:

  1. CI checks out code
  2. CI runs go mod download (implicitly by building)
  3. CI runs verify-modules before the real build steps
  4. If any module digest is missing from Rekor:
    • the job fails
    • you never compile a binary from that dependency set

This turns “trust verification” into a hard gate, not a late audit.


Limitations and the pragmatic fixes I used

A few practical issues came up:

  • Rekor API differences: query parameters differ across Rekor versions and entry types. I kept the verifier query simple (hash=...) but you may need to adjust to your Rekor schema.
  • Module cache escaping: my splitHostAndPath fallback assumes lowercase module paths. Production-grade setups should use the official Go module escaping rules (golang.org/x/mod/module helpers).
  • Policy beyond presence: presence of a digest in Rekor proves “something was logged,” but real deployments often require:
    • signer identity constraints (which account is allowed to attest)
    • attestation subject constraints (module path/version fields)
    • time window constraints (e.g., only accept attestations older than build start, or vice versa)

The structure in the code is set up so those checks can be added once Rekor entry parsing is aligned with your environment.


Conclusion

I built a small verifier that ties together Go module fetching and Rekor transparency log lookups using Sigstore-style trust assumptions: the build proceeds only when the exact SHA-256 digest of each downloaded module zip is found in Rekor. The biggest lesson from tinkering was that supply chain trust can’t stop at “signed releases”—it has to anchor the exact bytes your build consumes, and that alignment (hash input fidelity + Rekor query compatibility) is what makes the defense real.