DEV Community

r4mimu
r4mimu

Posted on

gomod-age: A Simple CI Gate Against Go Dependency Supply Chain Attacks

The Problem Nobody Talks About Until It's Too Late

Here's a scenario that keeps Go developers up at night: someone publishes a malicious package to a module proxy, and your CI pipeline happily pulls it in on the next go mod tidy. The package is minutes old, has zero adoption, and contains a backdoor. Your tests pass. Your linter is green. You ship it to production.

This isn't hypothetical. Supply chain attacks targeting package registries have been climbing year over year. The event-stream incident in npm, the ua-parser-js hijack, the colors.js sabotage — these are well-known cases, but the Go ecosystem isn't immune. Typosquatting, account takeovers, and dependency confusion attacks all apply.

Most teams rely on go.sum for integrity checks and GOPRIVATE for internal modules. That covers "did the bits change?" but not "should we trust this release at all?" There's a gap between a version being published and a version being safe to consume. Nothing in the standard Go toolchain guards that window.

What gomod-age Does

gomod-age is a single-binary CLI that checks whether your Go dependencies have been published long enough. If a module version is younger than a configurable threshold (default: 3 days), the check fails. That's it. No accounts to create, no SaaS to integrate, no vulnerability database to query.

The idea is dead simple: legitimate releases age like wine. Malicious releases get reported and yanked quickly. By enforcing a quarantine period, you let the community's immune system do its job before you consume a new version.

It queries the Go module proxy (GOPROXY) for the publish timestamp of each dependency, compares it against the threshold, and exits non-zero if anything is too fresh. The entire check runs in seconds thanks to concurrent proxy queries.

Where It Fits in Your Workflow

gomod-age is designed as a CI gate, not a development-time annoyance. You run it in pull request checks alongside your tests and lints. When a PR adds or updates a dependency, gomod-age tells you if any of those versions were published too recently.

The typical CI pattern looks like this with GitHub Actions:

- uses: actions/checkout@v4
  with:
    fetch-depth: 0
- uses: actions/setup-go@v5
  with:
    go-version-file: go.mod
- run: go install github.com/fchimpan/gomod-age@latest
- run: gomod-age -base origin/${{ github.base_ref }}
Enter fullscreen mode Exit fullscreen mode

The -base flag is the key here. Instead of checking every dependency in your go.mod, it diffs against the base branch and only checks modules that changed. This means existing dependencies don't trigger false positives just because you happen to run the check within 3 days of their release.

Usage

Install it:

go install github.com/fchimpan/gomod-age@latest
Enter fullscreen mode Exit fullscreen mode

Run it locally to see what it reports:

# Check all dependencies
gomod-age

# Only check what changed relative to main
gomod-age -base origin/main

# Bump the threshold to 7 days
gomod-age -age 7d

# Include indirect dependencies too
gomod-age -indirect

# Machine-readable output
gomod-age -json
Enter fullscreen mode Exit fullscreen mode

The text output is a table showing violations, with the module path, version, publish time, current age, and how much time remains before it clears the threshold:

VIOLATIONS (1):
MODULE                                             VERSION      PUBLISHED              AGE        REMAINING
--------------------------------------------------------------------------------------------------------------
github.com/example/suspiciously-new                v0.1.0       2026-04-03T10:00:00Z   1d2h       1d22h

Summary: 42 passed, 1 violations, 3 skipped, 0 errors
Enter fullscreen mode Exit fullscreen mode

Exit codes are straightforward: 0 means all clear, 1 means violations found, 2 means a tool error (bad flags, network issues, etc.).

Handling Real-World Complexity

A few things that come up in practice:

Private modules

gomod-age reads GOPRIVATE and automatically skips those modules. Internal packages hosted on your own registry don't need age checks — you control when they're published.

Ignoring specific modules

Sometimes you know a freshly-published module is fine. Maybe your team owns it, or you've reviewed the release manually. Use the -ignore flag with glob patterns:

gomod-age -ignore "github.com/my-org/*"
Enter fullscreen mode Exit fullscreen mode

Allow list for reviewed versions

For one-off exceptions, the config file supports an allow list:

# .gomod-age.yaml
age: 7d
ignore:
  - "github.com/my-org/*"
allow:
  - module: github.com/new-lib/foo
    version: v1.2.0
    reason: "reviewed in PR #42"
Enter fullscreen mode Exit fullscreen mode

The allow entry is pinned to an exact module@version. When the version changes, the age check kicks in again. This prevents a "reviewed once, trusted forever" blind spot.

Replace directives

If your go.mod uses replace directives, gomod-age checks the replacement module's publish time, not the original. This handles the common pattern of forking a dependency while maintaining accurate age checks.

GOPROXY chain

gomod-age respects the full GOPROXY fallback chain, including the difference between comma separators (fall through on 404/410 only) and pipe separators (fall through on any error). If you're running a private proxy like Athens in front of proxy.golang.org, it just works.

What It Doesn't Do

gomod-age is not a vulnerability scanner. It doesn't check CVE databases, it doesn't analyze source code, and it doesn't score packages by reputation. Tools like govulncheck and Snyk handle that.

It's a single, narrow check: "is this version old enough to trust?" That narrowness is a feature. It composes with your existing security tools rather than trying to replace them.

Wrapping Up

Supply chain security is a layered problem. go.sum handles integrity. govulncheck handles known vulnerabilities. Code review handles logic. But none of these catch a malicious version in the hours after it's published and before it's flagged.

gomod-age fills that gap with a 30-second check that requires zero configuration. Add it to your CI pipeline, forget about it, and let it catch the dependency updates that deserve a closer look.

The source is at github.com/fchimpan/gomod-age. It's a single go install away.

Top comments (0)