Home Writing Go Linters
Post
Cancel

Writing Go Linters

Recently I looked into writing a custom linter for an open-source project called defsec. We had a fairly unique problem with an all-too-frequent bug. We decided if we could catch this type of bug at development time with a linter, we could not only fix things faster (instead of waiting for an integration test to fail), but we could also consistently prevent the bug from happening in the first place. I was pleasantly surprised to discover that this was not only trivially possible, but an all round pleasant experience…

Useful Packages

One of the most celebrated aspects of the Go language is its tooling, and perhaps one of the reasons this tooling is both abundant and great is the quality of resources available to developers.

Before we can analyse and lint Go code, we need to be able to parse it into logical components. Go provides some excellent core packages for parsing the language, notably go/parser and go/ast.

Using these, it’s quite easy to parse a Go source file into an AST. If you’re not familiar, an abstract syntax tree is a tree structure where each node represents a single construct from the source code.

Here’s an example of parsing a source file into an AST, and printing out each node of the AST recursively:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import (
    "fmt"
    "go/ast"
    "go/parser"
    "go/token"
)

func main() {

    source := `package main

import "fmt"

func main() {
  fmt.Println("hello world!")
}
`

    parsed, err := parser.ParseFile(token.NewFileSet(), "", source, parser.AllErrors)
    if err != nil {
        panic(err)
    }

    ast.Inspect(parsed, func(node ast.Node) bool {
        fmt.Printf("%#v\n\n", node)
        return true
    })
}

The above takes the code:

1
2
3
4
5
6
7
package main

import "fmt"

func main() {
  fmt.Println("hello world!")
}

And turns it into the following (simplified) AST:

AST

The line fmt.Println("hello world") is made up of the following nodes. I’ve colour-coded the diagram so it’s clearer which parts of the code are translated into which AST nodes.

hello world

You can run the code yourself to dig deeper if you like.

Once you begin to familiarise yourself with the AST, it becomes clear that you have the building blocks for a linter - and lots of other tools besides!

go/analysis

The go/analysis package provides an awesome toolkit for building a linter. It sets up command line flags and arguments, formats output, and even handles the reading of the source code for you. All that’s left for you to worry about is the linting itself. You simply need to flesh out an Analyzer struct and implement a function with the signature func(*goanalysis.Pass) (interface{}, error).

Here’s a complete example linter than looks for functions named ‘silly’:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import (
  "go/ast"
  "golang.org/x/tools/go/analysis"
  "golang.org/x/tools/go/analysis/singlechecker"
)

func main() {
  singlechecker.Main(&analysis.Analyzer{
    Name: "mylinter",
    Doc:  "Checks for functions named 'silly'",
    Run: func(pass *analysis.Pass) (interface{}, error) {
      for _, file := range pass.Files {
        for _, declaration := range file.Decls {
          if function, ok := declaration.(*ast.FuncDecl); ok {
            if function.Name.Name == "silly" {
              pass.Reportf(function.Pos(), "function should not be named 'silly'")
            }
          }
        }
      }
      return nil, nil
    },
  })
}

Running the above without any arguments will spit out something like the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
mylinter: Checks for functions named 'silly'

Usage: mylinter [-flag] [package]


Flags:
  -V    print version and exit
  -all
        no effect (deprecated)
  -c int
        display offending line with this many lines of context (default -1)
  -cpuprofile string
        write CPU profile to this file
  -debug string
        debug flags, any subset of "fpstv"
  -fix
        apply all suggested fixes
  -flags
        print analyzer flags in JSON
  -json
        emit JSON output
  -memprofile string
        write memory profile to this file
  -source
        no effect (deprecated)
  -tags string
        no effect (deprecated)
  -test
        indicates whether test files should be analyzed, too (default true)
  -trace string
        write trace log to this file
  -v    no effect (deprecated)
exit status 1

You can see that the go/analysis package has done all of the heavy lifting and set up the flags, args etc. for us!

Pointing it at a directory or file containing a function named silly will cause it to print out the offending line, like so:

It works!

We already have a working linter in 26 lines of Go!

Most of the code is spent looking through the AST for function declarations (*ast.FuncDecl). We can instead use the inspect package to do this for us. We need to add the inspect.Analyzer to the Requires property of our Analyzer struct, then we can use it like so:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import (
    "go/ast"
    "golang.org/x/tools/go/analysis"
    "golang.org/x/tools/go/analysis/passes/inspect"
    "golang.org/x/tools/go/analysis/singlechecker"
    "golang.org/x/tools/go/ast/inspector"
)

func main() {
    singlechecker.Main(&analysis.Analyzer{
        Name:     "mylinter",
        Doc:      "Checks for functions named 'silly'",
        Requires: []*analysis.Analyzer{inspect.Analyzer},
        Run: func(pass *analysis.Pass) (interface{}, error) {
            insp := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
            insp.Preorder([]ast.Node{
                (*ast.FuncDecl)(nil),
            }, func(n ast.Node) {
                if function := n.(*ast.FuncDecl); function.Name.Name == "silly" {
                    pass.Reportf(function.Pos(), "function should not be named 'silly'")
                }
            })
            return nil, nil
        },
    })
}

The inspector.Preorder function can be used to walk the AST and discover the specific node types we’re interested in - in this case *ast.FuncDecl, making the example a little cleaner.

Summary

Go linters are an incredibly powerful tool - especially so given the ease with which they can be developed!

Whilst they should never be a replacement for tests, I’ve found that custom linters can often highlight a problem much earlier in the development cycle, and are generally efficient to write for common bug types.

I hope you enjoyed these hints from my stint minting linters.

This post is licensed under CC BY 4.0 by the author.

Write-up: Intigriti 0722 (July 2022) XSS Challenge

Scanning for AWS Security Issues With Trivy

Comments powered by Disqus.