DEV Community

Anakin
Anakin

Posted on

Testing That Secrets Cannot Reach Logs in Go

Nobody plans to log a password. It usually happens when someone is debugging a production issue, prints a struct to see what is going on, fixes the bug, and leaves the log line behind. The review misses it because the change is small and the struct looks harmless. Then your log pipeline does exactly what you asked it to do: collect, index, replicate, and retain the secret.

Policies help, but they do not catch this class of mistake reliably. A better control makes the unsafe thing hard to express and tests the paths where it can still happen.

Redaction has to cover more than String()

In Go, the first version often looks like this:

type DBConfig struct {
    Host     string
    User     string
    Password string
}

func (c DBConfig) String() string {
    return fmt.Sprintf("DBConfig{Host:%q User:%q Password:[REDACTED]}", c.Host, c.User)
}
Enter fullscreen mode Exit fullscreen mode

That helps for this case:

fmt.Printf("%v\n", cfg)
Enter fullscreen mode Exit fullscreen mode

But it does not cover every route to a log line. This leaks the password:

fmt.Printf("%#v\n", cfg)
// main.DBConfig{Host:"db.internal", User:"app", Password:"correct-horse"}
Enter fullscreen mode Exit fullscreen mode

%#v prints a Go-syntax representation. If your redaction only lives in String(), you have not covered that path.

The safer pattern is to make the secret field itself refuse to render as cleartext:

type Secret string

func (Secret) String() string {
    return "[REDACTED]"
}

func (Secret) GoString() string {
    return "[REDACTED]"
}

func (Secret) MarshalJSON() ([]byte, error) {
    return []byte(`"[REDACTED]"`), nil
}

type DBConfig struct {
    Host     string `json:"host"`
    User     string `json:"user"`
    Password Secret `json:"password"`
}
Enter fullscreen mode Exit fullscreen mode

In Wire, we use this kind of type-level redaction for values that may hold vault tokens, resolved credentials, or connection material, because the debug line is the realistic failure mode.

This is still not magic. If someone converts Secret back to string, they can leak it. If a logging library uses reflection or custom encoders, you may need to implement its redaction interface too, such as slog.LogValuer or a zap marshaler. The point is not that one method solves everything. The point is that the type should make accidental logging fail closed in the common paths.

Test the paths, not the intention

A useful test does not assert that String() returns [REDACTED]. That is too narrow. It fills every secret-bearing type with a canary value and tries to render it the ways developers actually render things while debugging.

Here is a stripped-down version:

package secrets_test

import (
    "encoding/json"
    "fmt"
    "strings"
    "testing"
)

type Secret string

func (Secret) String() string { return "[REDACTED]" }
func (Secret) GoString() string { return "[REDACTED]" }
func (Secret) MarshalJSON() ([]byte, error) {
    return []byte(`"[REDACTED]"`), nil
}

type DBConfig struct {
    Host     string `json:"host"`
    User     string `json:"user"`
    Password Secret `json:"password"`
}

func TestSecretDoesNotReachRenderedOutput(t *testing.T) {
    const canary = "CANARY_PASSWORD_DO_NOT_LOG_12345"

    cfg := DBConfig{
        Host:     "db.internal",
        User:     "app",
        Password: Secret(canary),
    }

    renderJSON := func(v any) string {
        b, err := json.Marshal(v)
        if err != nil {
            return err.Error()
        }
        return string(b)
    }

    cases := map[string]string{
        "fmt percent v":      fmt.Sprintf("%v", cfg),
        "fmt percent plus v": fmt.Sprintf("%+v", cfg),
        "fmt percent sharp v": fmt.Sprintf("%#v", cfg),
        "inside slice":       fmt.Sprintf("%#v", []DBConfig{cfg}),
        "inside map":         fmt.Sprintf("%#v", map[string]DBConfig{"primary": cfg}),
        "inside struct":      fmt.Sprintf("%#v", struct{ Config DBConfig }{cfg}),
        "wrapped error":      fmt.Errorf("connect failed: %v", cfg).Error(),
        "json":               renderJSON(cfg),
    }

    for name, got := range cases {
        if strings.Contains(got, canary) {
            t.Fatalf("%s leaked secret: %s", name, got)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This test catches boring mistakes. That is exactly why it is worth having.

If someone changes Password Secret back to Password string, the test fails. If someone adds a new secret field and forgets to wrap it, the test fails. If someone adds JSON output but forgets MarshalJSON, the test fails.

The failure is immediate and specific:

--- FAIL: TestSecretDoesNotReachRenderedOutput (0.00s)
    secrets_test.go:54: fmt percent sharp v leaked secret: secrets_test.DBConfig{Host:"db.internal", User:"app", Password:"CANARY_PASSWORD_DO_NOT_LOG_12345"}
Enter fullscreen mode Exit fullscreen mode

That is much better than discovering the same string in CloudWatch, Datadog, or Elasticsearch after retention and replication have already done their work.

Know what this does not prove

This kind of test proves that your supported rendering paths do not expose the canary. It does not prove that no code anywhere can access the underlying bytes. Your application still needs to pass the cleartext to a database driver, API client, or vault SDK at some point.

It also does not cover every logger automatically. Structured loggers often avoid fmt and encode fields directly. For Go's log/slog, implement LogValue() on the secret type:

func (Secret) LogValue() slog.Value {
    return slog.StringValue("[REDACTED]")
}
Enter fullscreen mode Exit fullscreen mode

Then add a test case that records through your actual logger and scans the emitted output for the canary. Do the same for zap, zerolog, or whatever your service uses.

Wire also treats related credential boundaries as testable behavior, for example failed connection attempts should not write partial credential state that later code might accidentally use.

There are other useful controls in the same family:

  • Zero plaintext buffers after use when you own the memory.
  • Do not persist failed credential configuration.
  • Check that credential references cannot point outside the scope they were granted.
  • Name tests after the security property, not the implementation detail.

A test named TestSecretDoesNotReachRenderedOutput explains the invariant you care about. A future refactor can change the implementation and still preserve the behavior.

Add a canary-based log-safety test around your own secret types, run it in CI, and extend it every time you add a new logger, serializer, or credential-bearing struct.

Top comments (0)