DEV Community

Bala Paranj
Bala Paranj

Posted on

Your Go Structs Are Leaking: 6 Encapsulation Fixes From a Security CLI

How returning pointers to internal slices, exposing mutable globals, and using pointer receivers on read-only methods let callers corrupt state in a Go CLI — and the exact fixes.


Go doesn't have private or protected. It has exported (uppercase) and unexported (lowercase). This makes encapsulation feel optional.

I audited my Go CLI project for encapsulation violations and found 10. Each one let a caller reach into a struct's internal state and mutate it — accidentally or intentionally. The mutations were silent: no error, no panic, no log. The struct just stopped working correctly.

Here are the 6 encapsulation issues I found, with the exact before/after code from the fix.

Pattern 1: Returning the Backing Slice

The Violation

// BEFORE: Caller gets a reference to the internal slice
func (c *Catalog) List() []ControlDefinition {
    return c.controls  // ← returns the backing array
}
Enter fullscreen mode Exit fullscreen mode

A caller that does controls := catalog.List() gets a direct reference to c.controls. Appending, sorting, or modifying controls mutates the catalog's internal state:

controls := catalog.List()
controls[0].ID = "HACKED"  // ← silently corrupts the catalog
Enter fullscreen mode Exit fullscreen mode

No copy was made. The caller and the catalog share the same memory.

The Fix

// AFTER: Caller gets their own copy
func (c *Catalog) List() []ControlDefinition {
    return slices.Clone(c.controls)
}
Enter fullscreen mode Exit fullscreen mode

One function call. slices.Clone creates a shallow copy. The caller can sort, filter, or modify their copy without affecting the catalog.

The same fix was applied to ControlParams.Raw():

// BEFORE
func (p *ControlParams) Raw() map[string]any {
    return p.params  // ← caller can delete keys
}

// AFTER
func (p *ControlParams) Raw() map[string]any {
    return maps.Clone(p.params)
}
Enter fullscreen mode Exit fullscreen mode

And to constructor inputs:

// BEFORE: Constructor stores the caller's slice directly
func NewEvaluator(cidrs []string) *Evaluator {
    return &Evaluator{trustedCIDRs: cidrs}  // ← caller can mutate after construction
}

// AFTER: Constructor clones the input
func NewEvaluator(cidrs []string) *Evaluator {
    return &Evaluator{trustedCIDRs: slices.Clone(cidrs)}
}
Enter fullscreen mode Exit fullscreen mode

Rule: If a method returns a slice or map, and the caller shouldn't mutate the source, return slices.Clone or maps.Clone.

Pattern 2: Returning Pointers to Internal State

The Violation

// BEFORE: Returns pointer into internal slice
func (s *Snapshot) FindAsset(id string) *Asset {
    for i := range s.Assets {
        if s.Assets[i].ID == ID(id) {
            return &s.Assets[i]  // ← pointer to internal element
        }
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The returned *Asset points directly into s.Assets[i]. Modifying the returned asset modifies the snapshot:

asset := snapshot.FindAsset("my-bucket")
asset.Properties["public"] = false  // ← silently modifies the snapshot
Enter fullscreen mode Exit fullscreen mode

This is dangerous in a security tool. An evaluation engine that fixes an asset's properties during analysis would corrupt the snapshot for subsequent controls.

The Fix

// AFTER: Returns a copy, not a pointer
func (s *Snapshot) FindAsset(id string) (Asset, bool) {
    assetID := ID(id)
    for i := range s.Assets {
        if s.Assets[i].ID == assetID {
            return s.Assets[i], true  // ← value copy
        }
    }
    return Asset{}, false
}
Enter fullscreen mode Exit fullscreen mode

Two changes:

  1. Returns Asset by value instead of *Asset — the caller gets a copy
  2. Returns (Asset, bool) instead of *Asset — the comma-ok pattern instead of nil-checking

Every caller changed from pointer-check to comma-ok:

// BEFORE
asset := snapshot.FindAsset("bucket-1")
if asset == nil { ... }

// AFTER
asset, ok := snapshot.FindAsset("bucket-1")
if !ok { ... }
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Pointer Receivers on Read-Only Methods

The Violation

// BEFORE: Pointer receiver on a method that only reads
func (s *ObservationStats) HasFirstObservation() bool {
    return !s.firstSeenAt.IsZero()
}

func (s *ObservationStats) CoverageSpan() time.Duration {
    return s.coverageSpan
}

func (s *ObservationStats) ObservationCount() int {
    return s.observationCount
}
Enter fullscreen mode Exit fullscreen mode

These methods only read fields — they never modify s. But the pointer receiver signals "this method might mutate" and prevents the struct from being used as a value in certain contexts.

More importantly, when Timeline.Stats() returned *ObservationStats, callers could modify the stats through the pointer, invalidating the timeline's internal state.

The Fix

// AFTER: Value receivers — these methods are read-only
func (s ObservationStats) HasFirstObservation() bool {
    return !s.firstSeenAt.IsZero()
}

func (s ObservationStats) CoverageSpan() time.Duration {
    return s.coverageSpan
}

func (s ObservationStats) ObservationCount() int {
    return s.observationCount
}
Enter fullscreen mode Exit fullscreen mode

And the parent method returns by value:

// BEFORE: Returns pointer to internal stats
func (t *Timeline) Stats() *ObservationStats {
    return &t.stats
}

// AFTER: Returns copy
func (t *Timeline) Stats() ObservationStats {
    return t.stats
}
Enter fullscreen mode Exit fullscreen mode

The caller's stats is now a snapshot of the timeline's state at the time of the call. Mutations don't flow back.

The same fix was applied to Timeline.History():

// BEFORE
func (t *Timeline) History() *EpisodeHistory { return &t.history }

// AFTER
func (t *Timeline) History() EpisodeHistory { return t.history }
Enter fullscreen mode Exit fullscreen mode

With EpisodeHistory methods switched from pointer to value receivers:

func (h EpisodeHistory) Count() int { return len(h.episodes) }
func (h EpisodeHistory) RecurringViolationCount(w kernel.TimeWindow) int { ... }
func (h EpisodeHistory) WindowSummary(w kernel.TimeWindow) (count int, first, last time.Time) { ... }
Enter fullscreen mode Exit fullscreen mode

Pattern 4: Exported Field Protecting Internal Invariant

The Violation

// BEFORE: Exported field lets callers bypass sync.Once initialization
type ExceptionConfig struct {
    Rules []ExceptionRule    // ← exported, directly mutable

    index map[kernel.ControlID][]*ExceptionRule
    once  sync.Once
}
Enter fullscreen mode Exit fullscreen mode

ExceptionConfig uses sync.Once to build an index from Rules on first access. If a caller modifies Rules after the index is built, the index is stale. The lookup returns wrong results — silently.

cfg := NewExceptionConfig(rules)
cfg.Rules = append(cfg.Rules, newRule)  // ← index is now stale
cfg.ShouldExcept(controlID, assetID)   // ← returns wrong answer
Enter fullscreen mode Exit fullscreen mode

The Fix

// AFTER: Private field with accessor returning defensive copy
type ExceptionConfig struct {
    rules []ExceptionRule    // ← unexported

    index map[kernel.ControlID][]*ExceptionRule
    once  sync.Once
}

func (c *ExceptionConfig) Rules() []ExceptionRule {
    if c == nil {
        return nil
    }
    return slices.Clone(c.rules)
}
Enter fullscreen mode Exit fullscreen mode

The rules field is now lowercase (unexported). The Rules() accessor returns a defensive copy. The caller can inspect rules but cannot corrupt the index.

Rule: If a struct uses sync.Once or any lazy-initialization pattern, the fields it initializes from must be unexported.

Pattern 5: Mutable Package-Level Variables

The Violation

// BEFORE: Package-level var — any caller can mutate
var EvaluatableTypes = []ControlType{
    TypeUnsafeState,
    TypeUnsafeDuration,
    TypeUnsafeRecurrence,
}

// Any package can do this:
controldef.EvaluatableTypes[0] = TypeUnknown  // ← corrupts global state
Enter fullscreen mode Exit fullscreen mode

Three package-level var slices were exported. Any caller could modify the global state, affecting every other consumer.

The Fix

// AFTER: Function returns a fresh slice every time
func EvaluatableTypes() []ControlType {
    return []ControlType{
        TypeUnsafeState,
        TypeUnsafeDuration,
        TypeUnsafeRecurrence,
    }
}
Enter fullscreen mode Exit fullscreen mode

varfunc. Each caller gets their own slice. No shared mutable state.

The same pattern was applied to S3IngestPermissions and KnownIncompatible:

// BEFORE
var S3IngestPermissions = []string{"s3:GetObject", "s3:ListBucket"}

// AFTER
func S3IngestPermissions() []string {
    return []string{"s3:GetObject", "s3:ListBucket"}
}
Enter fullscreen mode Exit fullscreen mode

Rule: If a package-level variable is a slice or map, make it a function. var says "shared mutable state." func says "here's your own copy."

Pattern 6: Construction Without Defense

The Violation

// BEFORE: Constructor stores caller's slice directly
func NewEvaluator(cidrs []string) *Evaluator {
    return &Evaluator{
        trustedCIDRs: cidrs,  // ← same backing array as caller
    }
}

// Caller
cidrs := []string{"10.0.0.0/8"}
eval := NewEvaluator(cidrs)
cidrs[0] = "0.0.0.0/0"  // ← eval.trustedCIDRs is now "0.0.0.0/0"
Enter fullscreen mode Exit fullscreen mode

The constructor stores the caller's slice without cloning. Post-construction mutations by the caller silently affect the evaluator.

The Fix

// AFTER: Clone at construction boundary
func NewEvaluator(cidrs []string) *Evaluator {
    return &Evaluator{
        trustedCIDRs: slices.Clone(cidrs),
    }
}
Enter fullscreen mode Exit fullscreen mode

The evaluator owns its own copy from the moment of construction. The caller can't affect it afterward.

When to Encapsulate

Not every field needs encapsulation. Here's when each fix applies:

Situation Fix Cost
Method returns internal slice/map slices.Clone / maps.Clone Allocation per call
Method returns pointer into slice Return by value + comma-ok Copy per call
Read-only method with pointer receiver Switch to value receiver None (often faster)
Exported field guards lazy init (sync.Once) Unexport + accessor One new method
Package-level var slice/map Convert to func None
Constructor stores caller's slice slices.Clone at construction One allocation

The cost is almost always one allocation. The benefit is that callers can't corrupt internal state — a class of bugs that is silent, non-reproducible, and extremely hard to diagnose.


These 6 patterns were identified in the Stave refactoring catalog, fixing 10 violations.

Top comments (0)