DEV Community

Cover image for Securing a Go Supply Chain: The Pipeline That Holds in 2026
Jules Robineau
Jules Robineau

Posted on • Originally published at jrobineau.com

Securing a Go Supply Chain: The Pipeline That Holds in 2026

TL;DR: a Go project's security does not live in a code review. It lives in the pipeline. Your supply chain is all the code and tools between your keyboard and production. In 2026, it is the front door for attacks. Here is the pipeline step by step, with the tools, and where each one breaks.

This article is for Go teams that ship to production, and for people who buy DevSecOps. Not for a demo.

Your code may be clean. Your supply chain is not.

Your supply chain is your software supply chain. It holds your dependencies, your base images and your build tools. The big recent attacks did not come through a bug in your code. They came through that chain.

Two examples marked 2025. In March, the GitHub Action tj-actions/changed-files was compromised (CVE-2025-30066). The attackers moved version tags to a poisoned commit. Every repo pinned to that tag ran the malicious code. The result: secrets dumped into public CI logs, across about 23,000 repositories.

In September, it was Shai-Hulud. The first self-propagating worm in the npm ecosystem. It runs when a package is installed. It scans the environment for secrets, steals tokens, then republishes poisoned versions on its own.

Neither attack was a bug at the victim's site. Both entered through a dependency or a build tool. Go is not npm. But Go pulls code and runs tools too. The pipeline is where you defend.

The setup: a real Go pipeline, not a diagram

I am talking about two real things. My own Go project, and the pipelines I have built for clients.

On my own Go project, here is what already runs. gitleaks scans every commit before it leaves. golangci-lint, with staticcheck, blocks any pull request that fails. Dependencies are pinned by hash, and a bot updates them. The final image is distroless and non-root. The CI token is read-only.

On client pipelines, I added three layers. SAST, which reads code without running it (SonarQube). DAST, which tests the running app (OWASP ZAP). And image scanning (Grype, Trivy). With a measured drop in findings.

This article walks the whole chain, stage by stage. Some stages run on my side today. Others are the ones I recommend to close the chain. I tell you which is which each time.

Step 1: block known vulnerabilities with govulncheck

govulncheck is the official tool from the Go team. It compares your dependencies and the standard library against the Go vulnerability database.

Its strength is one idea. It reads your call graph. It only reports a vulnerability if your code actually reaches the vulnerable function. So you get far fewer false alarms than with a generic scanner. One line is enough in CI:

govulncheck ./...
Enter fullscreen mode Exit fullscreen mode

Where it breaks. govulncheck only knows about published flaws. A zero-day slips through. A zero-day is a flaw still unknown to defenders. And malicious code with no CVE slips through too. A CVE is the public ID of a known flaw. Step 3 closes that gap.

Step 2: pin your dependencies, and let a bot raise them

Go already pins your dependencies by hash, in go.sum. If a published version changes under you, the build fails. Keep -mod=readonly in CI, so nothing edits your deps silently.

But pinned does not mean safe. You can happily pin malware. So you also need managed updates. A bot opens dependency PRs every day.

On my side, minor updates merge on their own once CI is green. Major versions get labeled and wait for a human. A major bump changes too much to be blind.

Where it breaks. A single patch can be poisoned, as in the tj-actions case. Auto-merging a malicious patch is a real risk. So: keep few dependencies, and never let a major through without review.

Step 3: go beyond the CVE, hunt behavior

Shai-Hulud had no CVE at install time. It was just code that runs and steals secrets. A CVE scanner sees nothing. So you add two things.

First, SAST on your own code. SAST reads your code without running it and flags risky patterns. Three tools come up often. Here is how I place them.

govulncheck is not in this table. It looks at known flaws in your deps, not at patterns in your code. The two are complementary.

Second, treat third-party code as suspect. Fewer dependencies. Read the diff of a new dependency before you add it. Prefer the standard library. Go helps here: a Go module does not run an install script, unlike npm. But go generate, cgo and build tags can run code at build time. So the build machine is still a target.

Where it breaks. SAST has false positives, and it cannot see runtime logic. No tool reads a dependency's mind. The real defense stays: fewer deps, and human review.

Step 4: generate an SBOM, because the CRA is coming

An SBOM is the inventory of every component in your build, with versions. Think of it as the ingredient label of your software.

Two tools do the job in Go: syft or cyclonedx-gomod. They produce a file in CycloneDX or SPDX format. Those are the two machine-readable formats. Generate it in CI, and attach it to every release.

Why now? Because of the CRA, the EU Cyber Resilience Act. From 11 September 2026, a manufacturer must report an actively exploited vulnerability within 24 hours. You cannot hold a 24-hour clock without knowing what is in your product. The SBOM is that map. The formal SBOM duty arrives with full application of the text in 2027. But you need it before the clock starts.

Where it breaks. A stale SBOM is theater. It only helps when correlated with a live vulnerability feed. Generate it on every build, not once a year.

Step 5: scan the image, not just the code

Your Go binary is not the whole artifact. The container ships an OS layer too. Scan the final image. Trivy or Grype read it and flag known flaws, in system packages and in the binary alike.

Shrink the target first. A multi-stage build compiles the binary, then copies it into a tiny base. I use a distroless base, non-root, with a static binary. No shell, no package manager, almost nothing to exploit.

FROM golang:1.25-bookworm AS builder
# ... go mod download, then:
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app /app
USER 65534:65534
Enter fullscreen mode Exit fullscreen mode

Where it breaks. A scanner only knows its database. A minimal image cuts the surface, but the base image ages. Rebuild and rescan often.

Step 6: get secrets out of the repo and the build

Shai-Hulud harvested the secrets sitting in the environment. Hence the rule: a secret never sits in the repo, and never bakes into an image layer.

gitleaks scans every commit for keys and tokens. On my side, it runs as a pre-commit hook. The same hook blocks any .env, .pem or .key file from being added.

The real secrets live in a dedicated manager, injected at deploy time. I use Infisical. A managed vault works too.

In the Docker build, pass secrets with --mount=type=secret. The secret is available during the build step, but it never writes to a layer.

Where it breaks. gitleaks catches known patterns. A secret leaked before the hook, or a new format, slips through. So: rotate your secrets as soon as they are exposed.

Step 7: harden GitHub Actions, your most exposed link

Your CI holds your keys and write access. tj-actions proved it. The attackers moved a version tag to a poisoned commit. Every repo pinned to that tag ran the payload. Three moves limit the damage.

One. Least privilege on the CI token. Set contents: read by default, and add write only where a job needs it. On my side, the lint and test jobs are read-only.

permissions:
  contents: read      # read-only by default
  id-token: write     # only to request a short OIDC token
Enter fullscreen mode Exit fullscreen mode

Two. Pin third-party actions by full commit hash, not by tag. A tag can move, a hash cannot. Many teams still pin by major tag, and that is a fair default. After tj-actions, pin third-party actions by SHA.

- uses: actions/checkout@a1b2c3d   # full hash (40 chars), not @v4
Enter fullscreen mode Exit fullscreen mode

Three. Use OIDC to authenticate to the cloud. OIDC lets a job prove its identity and receive a short token, with no stored key. A stolen log or a leaked environment then holds a token that is already dead. That is the direct answer to tj-actions and to Shai-Hulud.

Where it breaks. SHA pinning protects against a moved tag, not against a compromised maintainer who publishes a release you then bump to. Reviewing version bumps is still needed.

Step 8: sign what you ship

The last lock. A signature proves an artifact comes from your pipeline, not from an attacker.

cosign, from the Sigstore project, signs an image or a file. With no key to manage, the signature ties to your CI identity. Your users verify it before they run the artifact. GitHub can also attach build provenance: a signed record of how and where it was built.

cosign sign   $IMAGE
cosign verify $IMAGE --certificate-identity ...
Enter fullscreen mode Exit fullscreen mode

Where it breaks. A signature proves origin, not innocence. A signed malicious artifact is still malicious. But you gain traceability and revocation. You know what to recall, and fast.

The supply chain checklist before you ship

Before you ship a Go service, walk your supply chain end to end.

  • [ ] govulncheck runs in CI and reads your call graph
  • [ ] Dependencies pinned by hash, build in -mod=readonly
  • [ ] A bot raises deps: minor automatic, major reviewed by a human
  • [ ] A SAST on your code (gosec or semgrep), on top of staticcheck
  • [ ] Few dependencies, and a new dep's diff read before adding it
  • [ ] An SBOM generated on every build, in CycloneDX or SPDX format
  • [ ] The final image scanned (Trivy or Grype), on a distroless non-root base
  • [ ] Secrets out of the repo, scanned by gitleaks, injected at deploy
  • [ ] Build secrets passed via --mount=type=secret, never in a layer
  • [ ] CI token set to contents: read by default
  • [ ] Third-party actions pinned by SHA, not by tag
  • [ ] OIDC for the cloud, instead of long-lived keys
  • [ ] Artifacts signed with cosign, build provenance attached

What to remember

A chain is only as strong as its weakest link. And that link is rarely your code. It is a moved tag, an auto-merged patch, a secret in a log.

Go gives you a real edge. Static binaries, a tiny attack surface, govulncheck reading your call graph, no install scripts by default. Use it. Then close the chain: pin, scan, inventory, sign.

The CRA is not the reason to do this. It is the deadline that removes your excuse. Are you building a Go pipeline and want a second pair of eyes on its supply chain? That is exactly what I do. Get in touch. Ship your service. Not the breach with it.


Sources: govulncheck (Go) · tj-actions / CVE-2025-30066 (Wiz) · tj-actions (CISA) · Shai-Hulud (Wiz) · Shai-Hulud (CISA) · CRA, 24h reporting (Black Duck) · CRA, Sept 11 2026 (Keysight) · CycloneDX (SBOM) · Sigstore / cosign.

Top comments (0)