cybersecurity

Go for Security Auditors: Part 2 - Finding The Doors Into A Codebase

The second in a three-part series on auditing Go code, covering how to orient in a large codebase, find entry points and attack surfaces, choose a review strategy, and use static analysis tooling effectively.

By Elmedin Burnik
Go for Security Auditors: Part 2 - Finding The Doors Into A Codebase
Photo by ChatGPT

Go for Security Auditors: Part 2 - Finding The Doors Into A Codebase

You have the repo cloned. go build ./... succeeded. Now what?

Large Go codebases can feel paralysing. There is no deep inheritance hierarchy to climb, no single entry point that explains everything. Instead you get dozens of packages, multiple binaries, and a flat structure that makes it unclear where anything important lives. The flat design is a feature of Go. It makes individual packages easy to understand in isolation. But it also means the architecture is implicit rather than explicit, and you have to reconstruct it yourself.

We have found that the first hour of orientation determines whether the rest of the review is productive or wasted. A reviewer who dives straight into a random package without context will spend days reading code that turns out to be irrelevant. A reviewer who spends twenty minutes finding the entry points and trust boundaries will know exactly where to focus.

This is Part 2 of a three-part series. If you missed it, Part 1 covered the syntax and language-level pitfalls that trip up auditors. Part 3 will cover the vulnerability patterns we find repeatedly in production Go code. This post is about finding the doors into a codebase and deciding which ones to open first.

Where to Start

The simplest and most underrated starting point is to ask the developer. The person who wrote the code knows where the important entry points are, what changed recently, what worries them, and what they would test if they had more time. A fifteen-minute conversation before you open the code can save hours of aimless reading. Ask them to walk you through the architecture verbally, point out the security-critical paths, and flag anything they are uncertain about.

If you do not have access to the developers, or you want to verify what they told you, the following locations are where you should start reading.

Binaries and Bootstrapping

Every Go binary starts with package main and func main(). In most projects, these live under a cmd/ directory with one subdirectory per binary. Running a quick search like rg -n "^func main\(" -t go reveals every entry point in the project.

The main function is where you see what gets initialised, in what order, and with what configuration. A typical main function looks something like this:

func main() {
    cfg := loadConfig()
    db := connectDB(cfg.DatabaseURL)
    svc := service.New(db, cfg)
    srv := &http.Server{
        Addr:    cfg.ListenAddr,
        Handler: svc,
    }
    if err := srv.ListenAndServe(); err != nil {
        log.Fatal(err)
    }
}

This tells you the dependency chain: config loads first, then database, then the service layer, then the HTTP server. If any of these initialisations are missing or misordered, you know where nil panics will come from. Pay attention to what happens when initialisation fails. Does the binary exit cleanly, or does it continue in a degraded state that might bypass security checks?

Starting from main has a clear upside: it gives you the architecture in miniature. You see the module dependencies, configuration flow, startup order, and the features that are enabled before any external input is processed. When issues do exist here, impact can be high because a bad dependency chain or failed initialisation can stop the binary from running at all, or leave it running with a security control disabled.

The trade-off is likelihood. Pure configuration bugs are often caught quickly during development and testing because the service fails to boot, and environment variables, config files, and CLI arguments are usually controlled by the DevOps team operating the node or protocol. Attackers often cannot change those inputs directly, so findings in this area tend to be low-likelihood or informational unless they create a degraded mode, insecure default, or deployment path that an operator is likely to use.

Ingress Surfaces

Every network handler is an attack surface. Before you read implementation details, map the ingress points. You want to know every place where external data enters the system.

The three most common network protocols you will see in Go services are HTTP, gRPC, and WebSockets. HTTP is the standard request-response model used for REST APIs and webhooks. gRPC is an RPC framework built on HTTP/2, usually defined by .proto files and used for service-to-service APIs. WebSockets start as HTTP connections and then upgrade into long-lived bidirectional channels, which makes them common for subscriptions, streams, and real-time peer communication.

For HTTP and REST services, look for router registration. The standard library uses http.HandleFunc and http.Handle, but most production code uses a router library. Chi registers routes with r.Get("/path", handler), Gin uses router.POST("/path", handler), and Echo uses e.GET("/path", handler). A search like rg -n 'Handle(Func)?\(|\.(Get|Post|Put|Delete|Patch|Options|Head|GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD)\(' -t go will surface most of them.

// Chi router example
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Route("/api/v1", func(r chi.Router) {
    r.Use(authMiddleware)
    r.Get("/accounts/{id}", getAccount)
    r.Post("/transfers", createTransfer)
})

For gRPC services, look for Register.*Server calls. These connect your service implementation to the gRPC server. The corresponding .proto files define the full API surface, and the generated _grpc.pb.go files show you the exact method signatures. Interceptors (gRPC's equivalent of middleware) handle cross-cutting concerns like authentication:

grpcServer := grpc.NewServer(
    grpc.UnaryInterceptor(authInterceptor),
)
pb.RegisterWalletServiceServer(grpcServer, walletSvc)

For WebSocket connections, look for upgrader usage. WebSockets are persistent connections that bypass the normal request-response cycle, which means authentication at connection time often gates the entire session, and per-message authorisation checks are easy to miss. For peer-to-peer systems, look for libp2p host creation, protocol handler registration, and custom TCP/QUIC listeners. Each registered protocol handler is an entry point that accepts data from potentially untrusted peers.

The security angle here is straightforward: every handler you find is a function that processes attacker-controlled input. Map them all before reading any implementation. Missing one is missing an attack surface.

Smart Contract Integration

If the system interacts with a blockchain, look for event listeners and contract bindings. Calls such as client.FilterLogs and client.SubscribeFilterLogs (usually via ethclient.Client) read or subscribe to onchain events. Generated ABI bindings (typically from abigen) create typed Go functions for contract interactions.

logs, err := client.FilterLogs(ctx, ethereum.FilterQuery{
    Addresses: []common.Address{contractAddr},
    Topics:    [][]common.Hash{{transferEventSig}},
})

The boundary between onchain and offchain code is where trust assumptions change. Onchain data has been validated by consensus, but it may still be reorged until finality and it still needs to match the expected chain, contract, and confirmation depth. Offchain interpretation of that data has not been validated by consensus. Watch for cases where the offchain code trusts event data without validating it against its own state, or where it assumes events arrive in a particular order.

Long-Lived Loops and Background Services

Go services typically run background goroutines that loop forever, processing events or performing periodic work. You saw the for { select { ... } } pattern in Part 1. These goroutines are started in Start() or Run() methods and are expected to run for the lifetime of the process.

func (w *Worker) Run(ctx context.Context) {
    ticker := time.NewTicker(30 * time.Second)
    defer ticker.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            w.processQueue()
        }
    }
}

Find these with rg -nU 'for\s*\{\s*select\s*\{' -t go or by searching for methods named Start, Run, or Loop. The security concern is goroutines that outlive their context. If a background worker does not respect cancellation, it becomes a resource leak. If it processes stale data after its context was cancelled, it might act on invalid state. Always trace the shutdown path and confirm that every long-lived goroutine has a clean exit.

Persistence and Boundaries

Data storage is a trust boundary. When data enters the system from an external source, gets validated, and then gets persisted, you need to confirm that the validation actually happened before the write. Look for database interfaces (*sql.DB, pgx, gorm), key-value stores (bbolt, pebble, leveldb, badger), caches (redis, in-memory maps), and file I/O.

func (s *Store) SaveAttestation(ctx context.Context, att *Attestation) error {
    if err := att.Validate(); err != nil {
        return fmt.Errorf("invalid attestation: %w", err)
    }
    return s.db.Put(att.Key(), att.Marshal())
}

The question to ask is: can you reach the persistence layer without going through validation? If there is a code path that writes directly to storage bypassing the validation function, that is a bug. Data that crosses a trust boundary needs validation on both sides: once when it enters the system and once when it is read back, because storage can be corrupted or tampered with.

Tests as Executable Documentation

Test files are underrated as a starting point for understanding a codebase. They show you how the code is meant to be used, what inputs are considered valid, and what invariants the developer thought were important. Integration tests are especially valuable because they exercise real system boundaries.

func TestTransfer_InsufficientBalance(t *testing.T) {
    svc := setupTestService(t)
    err := svc.Transfer(ctx, from, to, largeAmount)
    require.ErrorIs(t, err, ErrInsufficientBalance)
}

Look at what is tested and, more importantly, what is not. If the test suite has comprehensive happy-path coverage but no tests for error conditions, malformed input, or concurrent access, those are the areas where bugs hide. Run rg --files -g '*_test.go' to find all test files, then look for integration tests (often tagged with build constraints or in separate packages).

Configuration and Feature Flags

The configuration system reveals what can be toggled, what has defaults, and what requires explicit setup. Look for types named Config, environment variable parsing (os.Getenv), CLI flag registration (flag.String, cobra, pflag), and file loaders (viper, YAML/TOML unmarshalling).

type Config struct {
    EnableTLS     bool   `yaml:"enable_tls" default:"true"`
    MaxRetries    int    `yaml:"max_retries" default:"3"`
    AdminBypass   bool   `yaml:"admin_bypass" default:"false"`
}

If a security feature can be toggled off via configuration, check whether the default is safe. Look for flags like --insecure, --skip-verify, or --disable-auth. These exist for development convenience but sometimes ship to production with unsafe defaults. Configuration is also where you find hardcoded secrets, default passwords, and overly permissive settings that were never tightened for deployment.

Alternative Ways to Orient

Starting from main and tracing outward works well for many codebases, but it is not always the best approach. When you are reviewing a library with no main function, a massive monorepo with dozens of binaries, or a partial diff rather than the full codebase, you need different strategies.

A vertical slice is often the most efficient approach for large systems. Pick one user-facing action (for example, "submit a transaction" or "authenticate a user") and trace it end-to-end through every layer: the HTTP handler that receives it, the service function that processes it, the validation logic, the database write, and the response. By the time you finish one slice, you understand the codebase's conventions, error handling patterns, and where trust boundaries live. You can then apply that understanding to other slices much faster.

Threat-first orientation starts from the threat model rather than the code. Sketch a quick data flow diagram showing the major components, mark where data crosses trust boundaries, identify where secrets live, and note external dependencies. Then probe the edges first: how does authentication work? What happens when an external service is down? Can a malicious peer crash the node? This approach is particularly effective when you have a short audit window and need to prioritise the highest-risk areas.

Data-first orientation follows sensitive data through the system. Pick something valuable (private keys, user credentials, financial amounts, authentication tokens) and trace its lifecycle: where is it created, how is it stored, where is it transmitted, and when is it destroyed? This approach naturally surfaces the most security-critical code paths because you are following the thing an attacker would target.

Log-first orientation works well when you can run the system locally. Start the service with verbose or debug logging enabled, trigger a request, and follow the log output to discover the real execution path. Developers log what they are worried about. Searching for log.Error, log.Warn, or log.Fatal in the codebase shows you what failure modes the developers anticipated and where they expect things to go wrong.

For blockchain systems, onchain-first orientation starts from the smart contract interactions and works backwards into the offchain code. Read the Solidity contracts or ABIs, understand what events are emitted, and then find the Go code that listens for those events and acts on them. This naturally leads you to the trust boundary between consensus-validated onchain data and the offchain interpretation layer.

Most reviews combine two or three of these approaches depending on the codebase size and audit timeline.

When to Stop Reading Docs and Start Reading Code

Documentation gives you a map, but maps lie. They go stale the moment someone refactors a package without updating the README. Code does not lie. It might be confusing or poorly structured, but it tells you exactly what the system does right now.

Read documentation until you can answer these questions: What does this system do and for whom? Who are the actors (users, admin, external services, peer nodes, smart contracts)? What are the primary inputs and outputs, and where do they enter and leave the system? Where are the trust boundaries, and what are the most dangerous inputs? What are the critical invariants the system must maintain (authentication, replay protection, balance conservation)? What external systems does it call, and what happens when those calls fail or time out?

Once you can answer those questions, even approximately, you have enough context to start reading code productively. For complex protocols, it can be worth spending longer in the specifications and design docs until the system model is clear. The danger is treating documentation as a substitute for implementation review. Documentation gives you the skeleton; code fills in the gaps, contradictions, and edge cases. Iterate between code and docs as questions arise, but let the code be your primary source of truth.

Tooling and Static Analysers

Tools do not replace manual review, but they surface patterns faster than grep alone. A well-configured linter run before you start reading code can highlight potential issues, reduce noise, and point you toward the areas that deserve manual attention. Think of static analysis as triage: it tells you where to look, not what to conclude.

Built-in and Official

Go ships with go vet, which catches a surprising number of real bugs: printf format mismatches, unreachable code, invalid struct tags, suspicious atomic usage, and more. Run it first on any codebase you are reviewing. For shadowing, install the external shadow analyzer from golang.org/x/tools and run it through -vettool:

go vet ./...
go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest
go vet -vettool="$(command -v shadow)" ./...

The shadow analyzer is particularly useful during audits because variable shadowing is a common source of subtle bugs, as we covered in Part 1. Formatting tools like gofmt and goimports are not security tools, but consistently formatted code is easier to review. If the codebase is not formatted, that tells you something about the development process.

Bug Detection

golangci-lint is the standard meta-linter for Go. It aggregates dozens of individual linters into a single tool with unified configuration. For security auditing, enable at least these linters:

# .golangci.yml
version: "2"
linters:
  enable:
    - gosec         # Security issues
    - errcheck      # Unchecked errors
    - govet         # Suspicious constructs
    - staticcheck   # Comprehensive correctness
    - ineffassign   # Unused assignments
    - unconvert     # Unnecessary conversions
    - gocritic      # Opinionated checks

errcheck deserves special mention. Unchecked errors in Go are not just sloppy code; they are security bugs. An unchecked error from a cryptographic operation means you might proceed with invalid output. An unchecked error from an authorisation check means you might grant access when you should deny it. nilaway from Uber is a newer tool that statically detects nil pointer dereferences. It is still maturing but already useful for catching cases where a function returns nil and the caller does not check.

Security-Focused

gosec scans for common security issues: hardcoded credentials, weak cryptographic algorithms, SQL injection via string concatenation, and unvalidated TLS configurations. It is opinionated and sometimes noisy, but the findings are worth triaging.

semgrep lets you write custom rules for patterns specific to the project you are reviewing. If you notice a recurring anti-pattern (for example, library code that calls panic() instead of returning an error), you can write a rule to find every instance:

rules:
  - id: library-panic
    patterns:
      - pattern: panic(...)
    paths:
      exclude:
        - "*_test.go"
        - "cmd/**"
    message: "Library code should return errors, not panic"
    languages: [go]
    severity: WARNING

Dependency Analysis

govulncheck is the official Go vulnerability scanner. Unlike generic dependency scanners, it analyses your code and call graph to surface vulnerabilities that can actually affect the program based on the vulnerable functions it reaches. This dramatically reduces false positives:

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

For a broader view of dependency health, install go-mod-outdated and run it with -update -direct to show direct dependencies with available updates:

go install github.com/psampaz/go-mod-outdated@latest
go list -u -m -json all | go-mod-outdated -update -direct

Stale dependencies are a supply chain risk. Transitive dependencies are your supply chain. Know what you are pulling in, and check whether any of it is unmaintained or has known issues.

Useful Search Patterns

Beyond dedicated tools, targeted searches reveal patterns that linters miss. These ripgrep commands are part of our standard first pass on any Go codebase:

# Explicit panics and fatal exits (exclude tests)
rg --type go --glob '!*_test.go' 'panic\(|log\.Fatal|os\.Exit'
 
# Unsafe package usage
rg --type go '"unsafe"'
 
# Type assertions to audit for missing comma-ok checks
rg --type go --glob '!*_test.go' '\.\([^)]+\)'

For context and timeout issues, search for context.Background() in non-test code. In a request handler, replacing the request context with context.Background() usually breaks cancellation propagation, so downstream work may continue even after the client disconnects unless another timeout is added. Search for functions that accept a context.Context parameter but never check ctx.Done() or pass it to downstream calls:

# context.Background in non-test production code
rg --type go --glob '!*_test.go' 'context\.Background\(\)'
 
# Functions accepting context (check if they actually use it)
rg --type go 'func.*ctx context\.Context' -l

If a function accepts a context but ignores it, either the context is unnecessary (and the signature is misleading) or cancellation is not propagated (and you have a potential resource leak).

What is Next

You now have a method for orientation: find the entry points, map the attack surface, pick a review strategy, and let tooling accelerate the process. The goal is not to read every line of code. It is to find the security-critical paths quickly and focus your attention where it matters most.

Part 3 is about what you find when you walk through those doors. We will cover the vulnerability patterns that appear repeatedly in Go codebases we have audited: panics that crash production nodes, goroutine leaks that hang indefinitely without a timeout, race conditions in signature verification, nil pointer dereferences from missing checks, and integer overflows in consensus-critical arithmetic.


Part 2 of 3. Next: Common Vulnerabilities.

Working on something in this space?

Sigma Prime audits Ethereum protocols, smart contracts, and consensus implementations.

Request a scoping call