Static Analysis and Security
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 rungovulncheck
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 undergo 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.