Jakub Jarosz

Security, Systems & Network Automation

Writing secure Go code

2024-11-02 Go

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 [email protected] 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 [email protected]: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:

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 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) {
    t.Parallel()
    ...
func TestParseMemoryUsage(t *testing.T) {
    t.Parallel()
    ...

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: [email protected]
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/[email protected]
    Fixed in: path/[email protected]

=== 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: [email protected]
    Fixed in: [email protected]
...

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: [email protected]
    Fixed in: [email protected]

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 [email protected]: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!