Bulletproof: Five Essential Techniques for Auditing Go Code

Writing Go code is pure fun: With a clear syntax and “no magic” semantics, Go has a shallow learning curve, and well-written Go code is straightforward to read and understand. However, when logical flaws hide behind cd clean-looking code, when combining two correct pieces of code subtly introduces a bug, or when seemingly harmless code carries a SQL injection right down to the database, it's about time to put some more effort into making your code bulletproof.

No programming language can protect you from flaws that manifest themselves above code level and make your code vulnerable, instable, or slow: Brittle app design, algorithms implemented wrongly, and other logical mismatches aren't something a compiler could catch. That's what peer reviews and teamwork in general is for, but having a few general techniques at your mental toolbelt can already get you quite far.

So here are five techniques to make sure your code is safe and robust.

Cast a wide net with golangci-lint

A linter is a program that analyzes code for potential errors, bad programming style (that could result in errors as well), and other kinds of flaws that aren't the compiler's concern.

Go's go-to linter (no pun intended) is golangci-lint. Well, in fact, it's a meta-linter that hosts a ton of individual linters for all kinds of checks, from arangolint (that verfies best practices for the arangodb client) to zerologint (that detects wrong usage of zerolog). Many are useful in a particular context only, and enabling too many of them slows down the linting step and lets the lint output spill over.

For a quick start, stick with the linters enabled by default. You can enable more linters later when you observe a specific need. The default linters do a lot already:

  • staticcheck does static code analysis, which can reveal errors such as invalid regular expressions, Printf having a dynamic first argument but no further arguments, the use of unbuffered channels with os/signal.Notify, and many more.
  • errcheck checks unchecked errors. (Pardon the tongue twister.) Many nasty bugs originate from unchecked errors, that's why you'll want to have (leave) this linter enabled.
  • govet does various background checks on your code, including mistakes in using sync/atomic, unreachable code, and unused function call results.
  • unused does what the name says: check for unused constants, variables, functions, and types.

Sure, YMMV, and teams would have to agree on a canonical set of linters, but it's better to start with something that not starting at all.

Know your CVEs with govulncheck

Closing security gaps in your code is non-negotiable, especially in times of LLMs that find security holes and write exploits for them within minutes.

Solid security checking starts with a well-maintained list of vulnerabilities. Go has such a list at vuln.go.dev and a tool that checks your code's dependencies listed in go.mod against known CVEs (Common Vulnerabilities and Exposures).

Maybe you're using govulncheck already without having realized it: gopls, Go's language server, runs govulncheck by default. It flags all imports that are affected by CVEs.

In its purest form, govulncheck is a binary that you can run on your code at any occasion.

Pro tip: Filippo Valsorda was tired of getting Dependabot alerts flagging issues that won't affect the code because the code wouldn't call into the vulnerable dependency. So he replaced Dependabot with govulncheck. But why is govulncheck so much better than Dependabot?

Because it determines if your code actually reaches the vulnerable parts of the dependency!

This step eliminates false positives effectively, leading to a quieter inbox and more time for deep thoughts.

Eliminate data races with the -race flag

If you apply a few simple rules to concurrent programming in Go, nothing could go wrong: Never share memory between goroutines, don't assume operations as atomic when they aren't, etc. But it's too easy to unintentionally write code that passes a pointer to another goroutine, for example. Now two goroutines can write to the same piece of data, and the last one writing wins. Now you have a data race. And pointers aren't always visible; they may “hide” inside other data types. Slices, maps, and structs with fields that are pointer types are among the usual suspects.

Failures caused by data races are non-deterministic, often dormant until three weeks in production. But there's a way to make data races pop up much earlier: The [race detector](, activated through the -race flag.

What does -race do? Used with either go test or go run, it makes the Go compiler insert instrumentation code to record all memory access. The runtime then watches for unsynchronized memory access and prints a warning if it detects one.

Naturally, the detector can only detect data races when they actually happen, so you'll want to run -race-instrumented code with realistic workloads, to increase the chance of triggering a data race.

Hunt security bugs with gosec

While govulncheck keeps external vulnerabilities at bay, ¢gosec dissects your code to find structural weaknesses inside. Whether you want to catch hard-coded credentials, insecure HTTP cookie configuration, poor file permissions on certain file operations, or unsafe data deserialization, gosec has a rule for it.

Very conveniently, gosec is a linter in the long list of golangci-lint linters, and surely, you have already set up golangci-lint after reading the first section above, didn't you? This is an entry barrier as low as it can get. Or run the standalone binary, either manually or in a commit hook, github action, or CI pipeline.

Break your own code with fuzzing

You probably know the saying: A junior developer builds code until it works, a senior developer builds code until it breaks. Because, code that works and code that's robust are two differnt things.

But how to deliberately break one's code? Let Go do it. Code breaks most often in edge cases that nobody thought about testing thoroughly. Because let's face it: many of us are prone to testing the obvious behavior—the “happy path”, if you will. So what's better than testing the un-obvious through generated tests.

Fuzz testing does exatly that. A fuzz tester basically generates random input to a test function. It takes an initial seed of test inputs, called test corpus, iteratively mutates this corpus by changing values, flipping bits, inserting bytes, etc, and feeds each mutation to the function to be tested.

If an input makes a test case fail, the fuzz tester saves the offending input to testdata/, so that the developer can reproduce this failure deterministically.

How to write Go tests goes beyond this little Spotlight, but the Go docs have you covered.

Summa summarum

Creating robust code requires a mix of thoughtful code review and a sensible set of (automated) tools. Which of the above tools isn't in your toolchain yet? Time to try it out!