Writing secure Go code
What does it mean to keep security in mind when writing Go code? Answering this question in one short article seems impossible. For this reason, we will narrow it down to a few specific practices. They will lead to writing robust, secure and performant code when applied continuously.
- How do we stay informed about the Go security announcements?
- How do we keep our Go code patched and up to date?
- How do we test our Go code focusing on security and robustness?
- What are CVEs, and where do we learn about the most common software vulnerabilities?
Mailing list
Let’s start with the most obvious place - the Go mailing list. We
need to subscribe to get all critical security information right from
the source. All releases that contain security fixes are announced to
the golang-announce@googlegroups.com
list. Once we
subscribe to the list, we can be sure we won’t miss any important
announcements.
Keeping Go version up to date
The second step is to keep the Go versions in our projects current. Even though we don’t use the latest and greatest language features, bumping the Go version gives us all security patches for discovered vulnerabilities. Also, the new Go version ensures compatibility with newer dependencies. It protects our applications from potential integration issues.
The third step is to learn which security issues and CVEs are
addressed in what Go releases. We can check it on the Go release history
website and then update it to the latest version in the
go.mod
files in our projects.
After upgrading to new versions of Go, we should ensure that the operation does not introduce compatibility and dependency problems, especially with third-party packages. It can be more risky when we work on large projects with tens and sometimes hundreds of direct and indirect package dependencies.
The point is to maintain the risk by eliminating potential dependency problems. The problems may include an urgent need to refactor the existing code to make it work with a new dependency. Examples of such issues include changed packages, APIs or function signatures.
Using Go tooling
We can concentrate on the project source code after we know we will use the Go version without security issues. We can start assessing code quality and security by employing static code analysers.
vet
Before installing and using third-party analysers, it’s a good idea
to use the Go “native” go vet
command.
We can use the go vet
command to analyse our Go code.
The go vet
command without arguments runs the tool with all
options allowed by default. The tool scans the source code and reports
potential issues. The issues include code syntax errors and certain
programming constructs that can cause problems during program
executions.
Most common issues include goroutine mistakes, unused variables and
unreachable areas of the codebase. The main advantage of using the
go vet
command is that it is a part of the Go toolbox.
In a separate article, we will dive deeper into the vet
details. The extensive documentation and examples are on the
go vet
website.
staticcheck
Staticcheck is another static code analyser. It’s a third-party linter that helps to find bugs and detects possible performance problems. It also enforces Go language styling. It offers code simplifications, explains discovered issues and suggests corrections with examples.
Besides running staticcheck in a CI pipeline, we can install
staticcheck
on our laptops as a standalone binary and scan
the code locally. Let’s install the latest version:
go install honnef.co/go/tools/cmd/staticcheck@latest
No errors on the terminal? If so, we are ready to run the scans. But first, let’s check the installed version to ensure everything looks good.
staticcheck --version
staticcheck 2024.1.1 (0.5.1)
Similarly to the go vet
, running
staticcheck
without arguments invokes all code analysers by
default. This approach plays nicely with the UNIX programming philosophy
of using sensible defaults and not forcing users to do unnecessary
paperwork.
Let’s see what the tool can find in the NGINX Agent GitHub repository. First, we need to clone it:
git clone git@github.com:nginx/agent.git
Then, we can run it from the root directory of the project:
➜ agent git:(main) ✗ staticcheck ./...
After a short moment, we are ready to check the scanning results. We can categorise the listed examples into three groups:
- packages, methods or functions that are deprecated, for example:
...
src/core/metrics/sources/cpu.go:111:9: times.Total is deprecated: Total returns the total number of seconds in a CPUTimesStat Please do not use this internal function. (SA1019)
...
test/component/nginx-app-protect/monitoring/monitoring_test.go:15:8: "github.com/golang/protobuf/jsonpb" is deprecated: Use the "google.golang.org/protobuf/encoding/protojson" package instead. (SA1019)
- unused variables and fields, for example:
src/core/metrics/sources/nginx_plus.go:74:2: field endpoints is unused (U1000)
src/core/metrics/sources/nginx_plus.go:75:2: field streamEndpoints is unused (U1000)
src/core/metrics/sources/nginx_plus_test.go:94:2: var availableZones is unused (U1000)
- possible problems related to the quality of the code, for example:
src/core/nginx.go:791:4: ineffective break statement. Did you mean to break out of the outer loop? (SA4011)
Now, we are ready to start analysing the highlighted issues. A detailed deep dive into the codebase is outside this introductory article’s scope. We will do deeper code analysis, show examples, and fix security and performance issues in upcoming articles.
For now, let’s take note of CWE websites that contain tons of information about listed weaknesses so we can study them at a later time:
- Unused variables
- Using deprecated constructs
golangci-lint
The third code analyser we are going to employ is
golangci-lint
. As with all Go tools, we can install it in a
variety of ways, including the go install
command:
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
Let’s verify if the installation went well and check the version:
golangci-lint --version
golangci-lint has version v1.61.0 built with go1.23.2
...
Perfect! All looks good.
Following the same principle of the least surprise,
golangci-lint
runs all linters when we invoke it with no
arguments.
Rule of Least Surprise: In interface design, always do the least surprising thing.
What happens when we check the cloned earlier agent
repository? Will golangci-lint
show us the same warnings
and suggestions? Let’s find out.
As previously, we will start scanning the project from its root directory.
➜ agent git:(main) ✗ golangci-lint run ./...
Almost immediately, we noticed a list of suggestions for improving the code! For example:
src/extensions/nginx-app-protect/monitoring/processor/nap_test.go:60:14: S1025: the argument is already a string, there's no need to use fmt.Sprintf (gosimple)
logEntry: fmt.Sprintf(`%s`, func() string {
^
src/plugins/common.go:85:5: S1009: should omit nil check; len() for []string is defined as zero (gosimple)
if loadedConfig.Extensions != nil && len(loadedConfig.Extensions) > 0 {
^
The linter points to exact files and lines that need our attention. Our job now is to assess the code, make changes, run the liner a second time and run all unit tests. If the tests are green, we can commit updated code. Job done! Ok, we still need to push it to the remote.
Detecting race conditions
Race conditions in our programs and libraries can occur when multiple goroutines try to access a resource concurrently. These conditions are detected when at least one goroutine tries to write (change) the resource. For example, the resource can be a global, package-level variable that acts as a counter. This situation in a program can lead to subtle, very hard-to-diagnose and detect bugs.
Go has native support for testing such conditions. We run tests using
the Go test
tool with the argument -race
. This
method will run the race detector and help identify problems in
concurent programs.
go test -race
There is one warning we need to remember. The detector can assess the executed code and will ignore code paths that are not executed. So, it’s crucial to run static code analysers first and make sure we do not have so-called dead code in our project.
When we tell Go: “Hey, run tests with the -race
argument”, the Go compiler compiles the code with the race detector
enabled. Then, tests are run, and possible race conditions are checked
at runtime. When races are detected, the tool will print a detailed
report. It will show what goroutines try to access which resources.
Another way to increase the chances of detecting concurrency issues
is to run tests in parallel. To do so we need to inform the runner
explicitly by adding t.Parallel()
to our tests.
Two tests executed in parallel
func TestParseDiskSpace(t *testing.T) {
.Parallel()
t...
func TestParseMemoryUsage(t *testing.T) {
.Parallel()
t...
Detecting race conditions and designing concurrent code is a vast and exciting topic that we will discuss in the future.
Scanning source code for vulnerabilities
govulncheck
We have a broad choice of tools that scan the codebase for known vulnerabilities listed in the CVEs database.
Our default tool for ensuring we develop and release safe code is
govulncheck
. We can install it locally on a developer’s
machine and run scans locally before committing and pushing our code to
a remote Git repository.
Optionally, we can integrate the scanning step with CI pipelines in GitHub or GitLab. Then, the scan can be invoked on each merge request to ensure we do not introduce vulnerabilities in the project.
govulncheck
is developed by the Go team. A dedicated
database of Go vulnerabilities provides information for the scanner.
Let’s install govulncheck
locally and try basic
functionality.
To install the latest version, we need to run the following command:
go install golang.org/x/vuln/cmd/govulncheck@latest
It’s time to check if the installation process went well:
govulncheck -version
Go: go1.23.2
Scanner: govulncheck@v1.1.3
DB: https://vuln.go.dev
DB updated: 2024-10-17 15:37:30 +0000 UTC
...
We are ready to run our first scan. Let’s clone the habit git repository. Then, navigate to its root directory and run the tool.
➜ habit git:(main) ✗ govulncheck
No vulnerabilities found.
It looks promising! We did not find vulnerabilities in the source
code. Are we done? Not really! We built the habit binary when the
go.mod
file defined the version of Go 1.18. The current
version is v1.23.2.
Let’s scan the habit binary, not the source code.
➜ habit git:(main) ✗ govulncheck -mode binary -show verbose habit
We run govulncheck
in the binary mode. It means that we
can scan any Go binary we have access to! We do not need source code!
Then, we run the scan in the verbose mode. It will show the complete
report broken into multiple sections. The last argument is the name of
the binary we want to scan.
Hmmm! This report does look different! What just happened?
Scanning your binary for known vulnerabilities...
Fetching vulnerabilities from the database...
Checking the binary against the vulnerabilities...
=== Symbol Results ===
No vulnerabilities found.
=== Package Results ===
Vulnerability #1: GO-2023-2186
Incorrect detection of reserved device names on Windows in path/filepath
More info: https://pkg.go.dev/vuln/GO-2023-2186
Standard library
Found in: path/filepath@go1.20.5
Fixed in: path/filepath@go1.20.11
=== Module Results ===
Vulnerability #1: GO-2024-3107
Stack exhaustion in Parse in go/build/constraint
More info: https://pkg.go.dev/vuln/GO-2024-3107
Standard library
Found in: stdlib@go1.20.5
Fixed in: stdlib@go1.22.7
...
Vulnerability #18: GO-2023-1878
Insufficient sanitisation of Host header in net/http
More info: https://pkg.go.dev/vuln/GO-2023-1878
Standard library
Found in: stdlib@go1.20.5
Fixed in: stdlib@go1.20.6
Your code is affected by 0 vulnerabilities.
This scan also found 1 vulnerability in packages you import and 18
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
The first section contains the most important message: No vulnerabilities found.
The remaining sections contain information about other vulnerabilities discovered in standard Go libraries. Ok, but are we affected? Is our program not secure?
The final scan report tells us we should not worry. Our program doesn’t appear to call these vulnerabilities! Happy days!
Your code is affected by 0 vulnerabilities.
This scan also found 1 vulnerability in packages you import and 18
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
Let’s update the go.mod
file and change the Go version
to the latest 1.23
. Next, we need to run
go mod tidy
to get all dependencies up to date. At this
point, we are ready to build the binary again.
➜ habit git:(main) ✗ go build -o habit cmd/main.go
Let’s rerun the scan.
➜ habit git:(main) ✗ govulncheck -mode binary -show verbose habit
Scanning your binary for known vulnerabilities...
Fetching vulnerabilities from the database...
Checking the binary against the vulnerabilities...
No vulnerabilities found.
That’s what we wanted! We upgraded the Go version, pulled dependencies and verified that our software and dependencies were free from CVEs.
gosec
gosec
is
a static code analyzer. It helps to find insecure code constructs. We
can install it locally on our laptops or run it as a GitHub Action in a
CI pipeline. As described earlier, golangci-lint
includes
the gosec
as a plugin and runs it as default on each code
scan.
Let’s give it a try and install the scanner locally.
go install github.com/securego/gosec/v2/cmd/gosec@latest
If we do not see errors, gosec
is ready for action.
Before running our first scan, let’s look at the menu:
gosec -h
gosec - Golang security checker
gosec analyses Go source code to look for common programming mistakes that
can lead to security problems.
...
We can use a long list of options and rules to configure the scanner behaviour. Going into details of specific options is outside of the scope of this article. A detailed tutorial on configuring, running and benefiting from this SAST tool is coming soon! Stay tuned!
To try gosec, we need to clone a GitHub repository with the Go code we want to scan.
Let’s clone the brutus repository. It’s an open-source experimental OSINT app for testing web server configuration.
git clone git@github.com:CyberRoute/bruter.git
Next, change our current directory to the project’s root directory and start scanning.
gosec ./...
After a couple of seconds, gosec
presents the scan
report. What can we learn immediately? We see a list of potential issues
sorted by severity and confidence. We know what part of the code needs
attention and what weakness classification the issue applies to.
Perfect! What’s next?
...
[/.../bruter/pkg/fuzzer/randomua.go:69] - G404 (CWE-338): Use of weak random number generator (math/rand or math/rand/v2 instead of crypto/rand) (Confidence: MEDIUM, Severity: HIGH)
68:
> 69: randomIndex := rand.Intn(len(userAgents))
70: return userAgents[randomIndex]
...
[/.../bruter/pkg/server/config.go:40] - G402 (CWE-295): TLS InsecureSkipVerify set true. (Confidence: HIGH, Severity: HIGH)
39: customTransport := &http.Transport{
> 40: TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
41: }
...
At this stage of our investigation, we can check reported CWEs and learn about details of listed weaknesses. For example, the second listed issue brings us to the CWE-295 website, where we can learn more about vulnerability.
Fuzzing
The last method of checking code quality and discovering vulnerabilities is fuzz testing. Fuzzing is a special kind of automated testing. It uses code test coverage to manipulate randomly generated input data.
It’s extremely helpful in finding potential security flaws like buffer overflows, SQL injections, DoS attacks and XSS attacks. The most crucial attribute of fuzzing is that many input combinations are generated automatically! Developers don’t need to scratch their heads trying to figure out hundreds, if not thousands, of input data combinations! What a relief!
We will focus on fuzzing in more detail in upcoming tutorials.
Most of the methods and testing techniques we discussed today are encouraged by OpenSSF foundation. Open source projects that want to get the Best Practice Badge are required to meet FLOSS criteria in areas like licencing, change control, vulnerability reporting, quality, security and static and dynamic security code analysis.
Stay secure, free from CVEs and enjoy programming!
As John Arundel says:
“Programming is fun, and you should have fun!”
Till next time!