Redact at the boundary¶
Goal. Route the free-form strings your process ships to telemetry, shipped logs, and third-party error surfaces through
redact.String/redact.Errorat a single choke point — so a leaked credential never crosses the boundary in the clear.
When to apply it¶
Apply redaction on the way out, at the last hop before data leaves your control:
- a telemetry / tracing exporter (OTLP, a vendor SDK);
- a log handler whose output is shipped to an aggregator;
- an error report sent to a crash service or issue tracker;
- any managed observability ingest (metrics labels, log fields).
Do not apply it to host-only debug logs that never leave the machine — those are inside your trust boundary and may need raw content to be useful.
Redact an error before you report it¶
The most common leak is an error message that quotes a URL, a flag, or a header.
redact.Error is nil-safe, so you can call it unconditionally:
import "gitlab.com/phpboyscout/go/redact"
func reportFailure(reporter Reporter, err error) {
// redact.Error == redact.String(err.Error()), and returns "" for nil.
reporter.CaptureMessage(redact.Error(err))
}
Redact fields before you ship a log line¶
Wrap the values you attach to a shipped log entry. A thin helper keeps the call site honest:
import (
"log/slog"
"gitlab.com/phpboyscout/go/redact"
)
// safeArgs redacts every string value before it reaches a shipped handler.
func safeArgs(args ...any) []any {
out := make([]any, len(args))
for i, a := range args {
if s, ok := a.(string); ok {
out[i] = redact.String(s)
continue
}
out[i] = a
}
return out
}
func logExportError(logger *slog.Logger, target string, err error) {
logger.Error("export failed",
safeArgs("target", target, "cause", err.Error())...,
)
}
Make it one choke point¶
The discipline is redact once, at the boundary — not scattered through your call sites, where it is easy to forget. Centralise it in the component that owns the outbound hop, so every string is sanitised whether or not the caller remembered:
import "gitlab.com/phpboyscout/go/redact"
// exportSpan is the single place spans leave the process. Every free-form
// attribute is redacted here, so callers upstream never have to remember.
func (e *Exporter) exportSpan(name string, attrs map[string]string) {
safe := make(map[string]string, len(attrs))
for k, v := range attrs {
safe[k] = redact.String(v)
}
e.sink.Send(name, safe)
}
redact.String is idempotent — String(String(s)) == String(s) — so a
string that was already redacted upstream is unharmed if it passes through the
boundary a second time. You never pay a penalty for redacting defensively.
Keep redacting upstream too¶
Boundary redaction is a safety net, not a guarantee. The pattern catalogue catches the common shapes (see the rule catalogue), but it cannot recognise a credential in a format it has never seen — a bespoke token, an internal ID scheme, a short opaque secret below the fallback threshold.
So where you know a string carries a secret in a non-standard format, strip or
avoid it at the source as well. Treat redact.String as the last line of
defence, not the only one:
- never build log/telemetry strings out of raw secrets you already hold;
- prefer passing an identifier over the credential itself;
- let boundary redaction catch the accidents you did not anticipate.
Related¶
- Redact HTTP headers — scrub header values in middleware.
- Threat model — why this boundary exists.
- The rule catalogue & its limits — what is and is not caught.