Static Analysis and Security

Updated

July 30, 2026

Overview

Go provides built-in and third-party tools for code quality and security analysis.

go vet

go vet ./...

Catches: - Printf format errors - Unreachable code - Suspicious constructs

staticcheck

go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck ./...

Catches: - Deprecated APIs - Simplifications - Performance issues

golangci-lint

# Install
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest

# Run all linters
golangci-lint run

govulncheck

go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...

Checks dependencies for known vulnerabilities.

gosec

go install github.com/securego/gosec/v2/cmd/gosec@latest
gosec ./...

Security-focused linter: - SQL injection - Hardcoded credentials - Weak crypto

Common Security Issues

// SQL injection - BAD
query := "SELECT * FROM users WHERE id = " + id

// Use parameters - GOOD
db.Query("SELECT * FROM users WHERE id = ?", id)

// Path traversal - BAD
path := filepath.Join(baseDir, userInput)

// Sanitize - GOOD
if strings.Contains(userInput, "..") {
    return errors.New("invalid path")
}

CI Integration

# .github/workflows/lint.yml
- name: Lint
  run: golangci-lint run

- name: Security scan
  run: |
    govulncheck ./...
    gosec ./...

Summary

Tool Focus
go vet Basic correctness
staticcheck Advanced analysis
golangci-lint Multiple linters
govulncheck Vulnerability scan
gosec Security issues

Worked example

Path traversal guards unit-tested (the boring fix scanners want).

Save as pathx.go and pathx_test.go. Then:

go mod init example
go test -v
go vet ./...
// pathx.go
package main

import (
    "errors"
    "path/filepath"
    "strings"
)

func UnderRoot(root, user string) (string, error) {
    if user == "" || strings.Contains(user, "..") {
        return "", errors.New("invalid path")
    }
    full := filepath.Clean(filepath.Join(root, user))
    root = filepath.Clean(root)
    sep := string(filepath.Separator)
    if full != root && !strings.HasPrefix(full, root+sep) {
        return "", errors.New("escape")
    }
    return full, nil
}
// pathx_test.go
package main

import (
    "path/filepath"
    "testing"
)

func TestUnderRoot(t *testing.T) {
    root := filepath.Join("var", "data")
    ok, err := UnderRoot(root, "a.txt")
    if err != nil || ok != filepath.Join(root, "a.txt") {
        t.Fatalf("got %q err=%v", ok, err)
    }
    if _, err := UnderRoot(root, "../etc/passwd"); err == nil {
        t.Fatal("expected error")
    }
}

Expected output:

=== RUN   TestUnderRoot
--- PASS: TestUnderRoot (0.00s)
PASS

More examples

SQL placeholder style vs string concat (pattern comparison only).

package main

import (
    "fmt"
    "strings"
)

func main() {
    q := "SELECT * FROM t WHERE id=?"
    args := []any{"42"}
    fmt.Println("placeholder query:", q, "args:", args)
    bad := "SELECT * FROM t WHERE id='" + "x' OR '1'='1" + "'"
    fmt.Println("concat is dangerous:", strings.Contains(bad, "OR"))
}

Expected output:

placeholder query: SELECT * FROM t WHERE id=? args: [42]
concat is dangerous: true

Runnable example

External scanners (staticcheck, gosec, govulncheck) need separate installs. This stdlib program demonstrates safe vs unsafe patterns those tools flag, and runs cleanly under go vet / go test.

Save as safe.go and safe_test.go. Then:

go mod init example
go test -v
go vet ./...
// safe.go
package main

import (
    "errors"
    "path/filepath"
    "strings"
)

// badQuery shows string-built SQL (do not use with real drivers).
func badQuery(id string) string {
    return "SELECT * FROM users WHERE id = '" + id + "'"
}

// goodQuery keeps user data out of the SQL string (placeholder style).
func goodQuery() (query string, args []any) {
    return "SELECT * FROM users WHERE id = ?", []any{"42"}
}

// safeJoin cleans user input and rejects paths that escape base.
func safeJoin(base, userInput string) (string, error) {
    if userInput == "" || strings.Contains(userInput, "..") {
        return "", errors.New("invalid path")
    }
    // Build under base, then verify the cleaned result still has base as prefix.
    full := filepath.Clean(filepath.Join(base, userInput))
    baseClean := filepath.Clean(base)
    sep := string(filepath.Separator)
    if full != baseClean && !strings.HasPrefix(full, baseClean+sep) {
        return "", errors.New("invalid path")
    }
    return full, nil
}
// safe_test.go
package main

import (
    "path/filepath"
    "testing"
)

func TestSafeJoin(t *testing.T) {
    base := filepath.Join("var", "data")
    ok, err := safeJoin(base, "notes.txt")
    if err != nil {
        t.Fatal(err)
    }
    if ok != filepath.Join(base, "notes.txt") {
        t.Fatalf("got %q", ok)
    }
    if _, err := safeJoin(base, "../etc/passwd"); err == nil {
        t.Fatal("expected rejection")
    }
}

func TestGoodQuery(t *testing.T) {
    q, args := goodQuery()
    if q == badQuery("x") {
        t.Fatal("good query should not embed user input")
    }
    if len(args) != 1 {
        t.Fatal("expected bound args")
    }
}

Expected output:

=== RUN   TestSafeJoin
--- PASS: TestSafeJoin (0.00s)
=== RUN   TestGoodQuery
--- PASS: TestGoodQuery (0.00s)
PASS

What to notice: Parameterized queries and path containment are the boring fixes gosec pushes for. go vet is free CI; layer staticcheck / golangci-lint / govulncheck once the module is real.

Try next: Install staticcheck and run it on this package. Introduce an unused write and watch the tool complain.