A reference guide for identifying silent, production-dangerous vulnerabilities in Go codebases. These issues are typically invisible in tests but cause resource exhaustion, data races, goroutine leaks, or security exposures in production.
- Resource Leaks
- Goroutine Leaks
- Nil / Uninitialized Pointer Dereferences
- Concurrency / Race Conditions
- Error Handling Anti-Patterns
- Memory Leaks / Unbounded Growth
- Kubernetes / Controller-Runtime Specific
- Security-Class Vulnerabilities
- Timeout / Deadlock Risks
Resources that are opened but never explicitly closed cause file descriptor exhaustion, socket accumulation, and memory retention over time.
Severity: High
Symptom: Excessive open TCP connections, memory growth, goroutines not exiting.
rest.HTTPClientFor, &http.Transport{}, and &http.Client{} each hold an internal connection
pool. If the client is not retained and CloseIdleConnections() is never called, idle connections
accumulate per call-site invocation.
// BAD — httpClient goes out of scope; connections never drained
httpClient, err := rest.HTTPClientFor(config)
cli, _ := kubernetes.NewForConfigAndClient(config, httpClient)
return &MyStruct{cli: cli} // httpClient not stored, never closed
// GOOD — retain and close
type MyStruct struct {
cli kubernetes.Interface
httpClient *http.Client
}
func (s *MyStruct) Close() {
s.httpClient.CloseIdleConnections()
}What to grep for:
rest.HTTPClientFor
&http.Transport{
&http.Client{
Severity: High
Symptom: Socket/file descriptor leak; connection cannot be reused.
Every http.Response.Body must be fully read and closed, even on error responses. Forgetting
defer resp.Body.Close() prevents the underlying TCP connection from being returned to the pool.
// BAD
resp, err := client.Do(req)
if err != nil {
return err
}
data, _ := io.ReadAll(resp.Body)
// GOOD
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)What to grep for:
client.Do(
http.Get(
http.Post(
Then verify each call site has defer resp.Body.Close().
Severity: Medium
Symptom: Open file handles or streaming connections never released.
Pod log streams, archive readers, and other io.ReadCloser values must be closed after use.
// BAD
logs, _ := podClient.GetLogs(podName, &opts).Stream(ctx)
scanner := bufio.NewScanner(logs)
// GOOD
logs, err := podClient.GetLogs(podName, &opts).Stream(ctx)
if err != nil {
return err
}
defer logs.Close()Severity: Medium
Symptom: File descriptor exhaustion under high load.
// BAD
f, err := os.Open(path)
if err != nil {
return err
}
data, _ := io.ReadAll(f)
// GOOD
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()Severity: High
Symptom: Open TCP connections and goroutines for keepalive/health checks.
// BAD
conn, _ := grpc.Dial(addr, opts...)
client := pb.NewMyServiceClient(conn)
// conn never closed
// GOOD
conn, err := grpc.Dial(addr, opts...)
if err != nil {
return err
}
defer conn.Close()Severity: Medium
Symptom: Internal goroutine inside the ticker never exits; goroutine leak.
// BAD
ticker := time.NewTicker(5 * time.Second)
for range ticker.C {
doWork()
}
// GOOD
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
doWork()
case <-ctx.Done():
return
}
}Severity: Medium
Symptom: DB connection not returned to pool; query hangs under load.
// BAD
rows, _ := db.QueryContext(ctx, sql)
for rows.Next() { ... }
// GOOD
rows, err := db.QueryContext(ctx, sql)
if err != nil {
return err
}
defer rows.Close()Severity: High
Symptom: Child context goroutine leaks; associated resources never freed.
// BAD
ctx, cancel := context.WithCancel(parentCtx)
doWork(ctx)
// cancel never called
// GOOD
ctx, cancel := context.WithCancel(parentCtx)
defer cancel()
doWork(ctx)Severity: High
Symptom: Same as 1.8 — internal timer goroutine leaks until parent context is done.
// BAD
ctx, _ := context.WithTimeout(parent, 30*time.Second)
doWork(ctx)
// GOOD
ctx, cancel := context.WithTimeout(parent, 30*time.Second)
defer cancel()
doWork(ctx)Goroutines that never exit consume memory and scheduler resources permanently. They are invisible until the process OOMs or the goroutine count metric spikes.
Severity: High
If the receiver exits early (e.g., on error), any goroutine blocked on a send to an unbuffered channel will hang forever.
// BAD — if receiver returns early, this goroutine is stuck
ch := make(chan Result)
go func() {
ch <- expensiveWork() // blocks forever if nobody reads
}()
result, ok := <-ch
if !ok {
return // goroutine above is now leaked
}
// GOOD — use select with context
go func() {
result := expensiveWork()
select {
case ch <- result:
case <-ctx.Done():
}
}()Severity: High
A goroutine that loops without checking ctx.Done() cannot be stopped externally.
// BAD
go func() {
for {
poll()
time.Sleep(5 * time.Second)
}
}()
// GOOD
go func() {
for {
select {
case <-ctx.Done():
return
case <-time.After(5 * time.Second):
poll()
}
}
}()Severity: Medium
In an errgroup loop, if one goroutine fails early the groupCtx is cancelled. Starting a new
g.Go(...) after that with groupCtx-dependent work wastes resources.
Severity: High — panics in production
Calling wg.Done() more times than wg.Add() causes an immediate panic.
// BAD
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
defer wg.Done() // double Done → panic
}()Severity: Medium
Symptom: Silent data race — caller returns while goroutines are still writing.
// BAD
var wg sync.WaitGroup
wg.Add(1)
go func() { defer wg.Done(); writeResult() }()
return result // goroutine may not have run yet
// GOOD
wg.Wait()
return resultSeverity: High
A function that spawns goroutines and then returns on error without cancelling them.
// BAD
func process(items []Item) error {
for _, item := range items {
go handle(item) // no way to stop these
}
if err := validate(); err != nil {
return err // goroutines above are now leaked
}
return nil
}
// GOOD — use errgroup or pass a cancellable context
g, ctx := errgroup.WithContext(parentCtx)
for _, item := range items {
item := item
g.Go(func() error { return handle(ctx, item) })
}
return g.Wait()Severity: High — panics in production
// BAD — var declares a nil map; writing panics
var m map[string]string
m["key"] = "value" // panic: assignment to entry in nil map
// GOOD
m := make(map[string]string)
m["key"] = "value"Severity: High — panics in production
A typed nil satisfies an interface non-nil check but panics on method call.
// BAD
var p *MyProcessor = nil
var i Processor = p // i != nil, but calling i.Process() panics
// GOOD — check the concrete value
if p == nil {
return errors.New("processor not initialized")
}Severity: High
// BAD
type Manager struct {
client *kubernetes.Clientset
}
m := &Manager{} // client is nil
m.client.Get(...) // panic
// GOOD — always initialize or guard before use
if m.client == nil {
return errors.New("kubernetes client not initialized")
}Severity: Medium
// BAD — val is zero/nil when err != nil
val, _ := foo()
process(val) // may panic or silently corrupt state
// GOOD
val, err := foo()
if err != nil {
return err
}
process(val)Severity: High
Applies to: Go versions before 1.22
All goroutines in the loop capture the same loop variable, which has moved to the last value by the time they run.
// BAD (Go < 1.22)
for _, v := range items {
go func() {
process(v) // all goroutines see the same v
}()
}
// GOOD — shadow the variable
for _, v := range items {
v := v // new variable per iteration
go func() {
process(v)
}()
}Note: Go 1.22+ fixes this by default, but existing code written before 1.22 still has the bug.
Severity: High
Symptom: Data corruption, intermittent wrong values, go test -race failures.
// BAD
type Counter struct{ n int }
func (c *Counter) Inc() { c.n++ } // not safe for concurrent use
// GOOD
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}Severity: High — panics in production
Go's built-in map is not safe for concurrent reads and writes. Use sync.Map or protect with a
mutex.
// BAD
var cache = map[string]string{}
go func() { cache["k"] = "v" }()
go func() { _ = cache["k"] }() // concurrent read+write → panic
// GOOD
var cache sync.Map
go func() { cache.Store("k", "v") }()
go func() { cache.Load("k") }()Severity: Medium
sync.Once runs the function exactly once. If the initialization fails, subsequent callers get
the zero value with no error.
// BAD
var once sync.Once
var client *Client
once.Do(func() {
client, _ = newClient() // error swallowed; client stays nil
})
use(client) // may be nil
// GOOD — use a dedicated sync primitive or return the error from onceSeverity: High
Reading a flag outside a lock is a data race even if the write is protected.
// BAD
if !initialized { // data race: read without lock
mu.Lock()
if !initialized {
init()
initialized = true
}
mu.Unlock()
}
// GOOD
if atomic.LoadInt32(&initialized) == 0 {
mu.Lock()
if atomic.LoadInt32(&initialized) == 0 {
init()
atomic.StoreInt32(&initialized, 1)
}
mu.Unlock()
}Severity: Medium
Symptom: Undetected write failures (e.g., flushed buffers not written to disk).
// BAD
defer f.Close() // error from Close() is dropped
// GOOD (in non-deferred context)
if err := f.Close(); err != nil {
return fmt.Errorf("closing file: %w", err)
}Severity: High
// BAD
result, _ = doImportantThing() // failure is invisible
// GOOD
result, err := doImportantThing()
if err != nil {
return fmt.Errorf("doImportantThing: %w", err)
}Severity: High
// BAD
go func() {
if err := work(); err != nil {
log.Error(err) // logged but caller never knows
}
}()
// GOOD — use errgroup or an error channel
g.Go(func() error { return work() })
if err := g.Wait(); err != nil {
return err
}Severity: Medium
// BAD
func wrap(err error) error {
return fmt.Errorf("context: %w", err) // if err is nil, this returns non-nil!
}
// GOOD
func wrap(err error) error {
if err == nil {
return nil
}
return fmt.Errorf("context: %w", err)
}Severity: Medium
Wrapping the same sentinel error twice can break errors.Is chains in older Go versions.
// BAD
err = fmt.Errorf("outer: %w", fmt.Errorf("inner: %w", ErrNotFound))
// errors.Is(err, ErrNotFound) may fail in some Go < 1.20 scenarios
// GOOD — wrap once, consistently
err = fmt.Errorf("outer: inner: %w", ErrNotFound)Severity: High
Symptom: Steady memory growth; OOM under long-running workloads.
// BAD
var cache = map[string]*BigObject{} // grows forever
// GOOD — use a cache with TTL/LRU eviction
cache := lru.New(1000) // boundedSeverity: Medium
Sub-slicing a large buffer and storing the sub-slice keeps the entire original array in memory.
// BAD
data := readHugeFile()
header := data[:100] // keeps the full data array alive
// GOOD — copy if you only need a small piece
header := make([]byte, 100)
copy(header, data[:100])Severity: Medium
Symptom: Excessive allocations under load; GC pressure.
// BAD — allocates a new string on every call
if string(body) == expectedBody { ... }
// GOOD
if bytes.Equal(body, []byte(expectedBody)) { ... }Severity: High
Symptom: Each loop iteration creates a new timer that is never stopped until it fires.
// BAD — leaks a timer per iteration
for {
select {
case <-time.After(5 * time.Second): // new timer every iteration
doWork()
case <-ctx.Done():
return
}
}
// GOOD — reuse the ticker
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
doWork()
case <-ctx.Done():
return
}
}Severity: Medium
// BAD — if consumer is slow, producer fills memory
ch := make(chan Event, userProvidedCount)
// GOOD — use a fixed, reasoned bound
ch := make(chan Event, 100)Severity: High
Symptom: Each admin API request creates a new http.Transport connection pool.
// BAD — called inside an HTTP handler
func (f *frontend) handleAdminRequest(w http.ResponseWriter, r *http.Request) {
cli, _ := kubernetes.NewForConfig(restConfig) // new transport each request
cli.CoreV1().Pods("").List(ctx, metav1.ListOptions{})
// cli and its transport are never closed
}
// GOOD — share a long-lived client or close explicitly
httpClient, _ := rest.HTTPClientFor(restConfig)
defer httpClient.CloseIdleConnections()
cli, _ := kubernetes.NewForConfigAndClient(restConfig, httpClient)Severity: Medium
Symptom: N clients = N connection pools to the same API server.
// BAD — three separate transports to the same cluster
cli, _ := kubernetes.NewForConfig(restConfig)
configcli, _ := configclient.NewForConfig(restConfig)
operatorcli, _ := operatorclient.NewForConfig(restConfig)
// GOOD — one shared transport
httpClient, _ := rest.HTTPClientFor(restConfig)
cli, _ := kubernetes.NewForConfigAndClient(restConfig, httpClient)
configcli, _ := configclient.NewForConfigAndClient(restConfig, httpClient)
operatorcli, _ := operatorclient.NewForConfigAndClient(restConfig, httpClient)Severity: Medium
Symptom: List/Get via informer always returns empty results.
// BAD
factory := informers.NewSharedInformerFactory(cli, 0)
lister := factory.Core().V1().Pods().Lister()
// factory.Start() never called — lister always empty
// GOOD
factory.Start(stopCh)
factory.WaitForCacheSync(stopCh)Severity: Medium
// BAD
go mgr.Start(ctx)
list := &corev1.PodList{}
mgr.GetClient().List(ctx, list) // cache may not be synced yet
// GOOD
go mgr.Start(ctx)
if !mgr.GetCache().WaitForCacheSync(ctx) {
return errors.New("cache failed to sync")
}
mgr.GetClient().List(ctx, list)Severity: High
Symptom: Background goroutine in the watch loop never exits.
// BAD
watcher, _ := cli.CoreV1().Pods("").Watch(ctx, metav1.ListOptions{})
for event := range watcher.ResultChan() { ... }
// watcher.Stop() never called
// GOOD
watcher, _ := cli.CoreV1().Pods("").Watch(ctx, metav1.ListOptions{})
defer watcher.Stop()
for event := range watcher.ResultChan() { ... }Severity: Critical
Symptom: MitM attacks possible; certificate validation bypassed.
// BAD
tlsConfig := &tls.Config{InsecureSkipVerify: true}
// GOOD — always validate certificates in production
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
RootCAs: certPool,
}Severity: Critical
// BAD
log.Infof("using token: %s", token)
log.Debugf("restConfig: %+v", restConfig) // may include bearer token
// GOOD — never log credentials
log.Infof("token configured (length: %d)", len(token))Severity: Critical — command injection
// BAD
exec.Command("bash", "-c", userInput).Run()
// GOOD — pass arguments as separate strings, never through a shell
exec.Command("myprogram", "--flag", sanitizedArg).Run()Severity: High
// BAD — predictable output
token := strconv.Itoa(rand.Int())
// GOOD
b := make([]byte, 32)
_, err := crypto_rand.Read(b)
token := hex.EncodeToString(b)Severity: High
Context: An unvalidated URL from user input could reach Azure IMDS (XXX.254.XXX.254).
// BAD
url := r.URL.Query().Get("target")
http.Get(url) // attacker can reach internal Azure metadata endpoints
// GOOD — validate scheme, host, and port against an allowlist
if !isAllowedURL(url) {
return api.NewCloudError(http.StatusBadRequest, ...)
}Severity: Medium
Suppression comments that mask real security findings should always have an explanatory comment.
// BAD
x := rand.Int() // #nosec
// GOOD
// Safe: this value is used only for non-security load balancing, not for tokens.
x := rand.Int() // #nosec G404Severity: High
Symptom: Process hangs indefinitely on unresponsive external service.
// BAD
client := &http.Client{} // no timeout
// GOOD
client := &http.Client{Timeout: 30 * time.Second}Severity: Medium
Symptom: Cancellation is accepted by the function signature but has no effect.
// BAD
func fetchData(ctx context.Context) error {
rows, err := db.Query(sql) // context not passed — cannot be cancelled
...
}
// GOOD
func fetchData(ctx context.Context) error {
rows, err := db.QueryContext(ctx, sql)
...
}Severity: High — deadlock on early return
// BAD
mu.Lock()
if err := validate(); err != nil {
return err // mu is never unlocked → deadlock
}
mu.Unlock()
// GOOD
mu.Lock()
defer mu.Unlock()
if err := validate(); err != nil {
return err
}Severity: High
Symptom: Sender blocks forever if receiver has already exited.
// BAD
resultCh <- value // blocks forever if receiver is gone
// GOOD
select {
case resultCh <- value:
case <-ctx.Done():
return ctx.Err()
}Severity: Medium
// BAD
wait.PollImmediateUntil(interval, conditionFunc, neverCancellingCtx.Done())
// if conditionFunc never returns true, this polls forever
// GOOD
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
wait.PollImmediateUntil(interval, conditionFunc, timeoutCtx.Done())