feat(obs): wire redacting slog handler into rune-mcp#126
Merged
Conversation
internal/obs/slog.go was full of TODOs that left rune-mcp logging
unfiltered: a Vault token in an error message, an envector_ API key in
an attr, or a labeled secret in a debug message all flowed through to
stderr (and the optional file tee) verbatim. This is a real exposure
on dev hosts where users tail ~/.rune/logs/rune-mcp.log over screen
share, and a worse one for any future production deploy that ships
logs off-host.
Implementation
--------------
filteringHandler wraps an inner slog.Handler. Each Record's Message
plus every string-typed attr (recursing into KindGroup, best-effort
on KindAny w/ fmt.Stringer) is rewritten through two regexes ported
1-for-1 from Python's _SensitiveFilter:
(sk-|pk-|api_|envector_|evt_)[a-zA-Z0-9_-]{10,}
(?i)(token|key|secret|password)["\s:=]+[a-zA-Z0-9_-]{20,}
Replacement keeps the first 8 chars of the match plus "***", so
prefixes like "evt_AABB***" stay grep-able for incident investigation
while the body is destroyed. Strings ≤8 chars get the full "***"
treatment — preserving prefix would leak the secret.
WithAttrs / WithGroup chain through the inner handler so logger.With(
slog.String("api_key", ...)) is also caught.
NewRequestID returns RFC 4122 v4 UUIDs from crypto/rand. Falls back
to "req-anon" on rand.Read failure rather than panicking — a working
log without per-request correlation beats a crashing server. Format
+ uniqueness + roundtrip-via-context are all covered.
Wiring
------
cmd/rune-mcp/main.go now always wraps the chosen TextHandler with
obs.NewHandler before SetDefault. Both stderr-only and tee-to-file
flows go through redaction; an unset RUNE_MCP_LOG_FILE no longer
means "skip filtering."
Tests
-----
Adds slog_test.go covering: each token prefix once, labeled-secret
edge cases (JSON quoting, =, " ", short-fail), filteringHandler vs
attrs / message / With(), UUID format + uniqueness across 1024 ids
+ context round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three issues surfaced by code review:
1. Regex 2 over-redacted across word boundaries.
`(?i)(token|key|secret|password)["\s:=]+[a-zA-Z0-9_-]{20,}` matched
the bare label as a substring of any longer identifier, so `mykey:`,
`keyboard=`, `keystore=`, `tokenizer:`, `my_key=` etc. were all
silently rewritten. Adds `\b` before the alternation and a regression
test covering the five common false-positive shapes.
2. KindLogValuer attrs bypassed redaction.
The doc comment claimed slog resolves LogValuer upstream of a
wrapping Handler — that's wrong; the resolution happens inside
the leaf Handler, after our filter has already passed the attr
through unchanged. A `slog.Any("token", customLogValuer)` call
therefore leaked the secret to the inner handler verbatim.
redactAttr now has a KindLogValuer case that calls Resolve() and
recurses on the result so it flows back through the same redactor.
3. Stringer panics took down the logger.
`s.String()` was called bare in the KindAny branch; a panicking
String() method would propagate up through slog.Logger and crash
the process during exactly the kind of incident where logs matter
most. safeStringer wraps the call in a per-attr recover so a bad
attr loses its own redaction (we leave it untouched rather than
drop it) but the rest of the record still emits.
Tests added:
- TestRedact_NoFalsePositive: 5 substring shapes that must NOT redact
- TestFilteringHandler_RedactsLogValuer: secretValuer slog.LogValuer
whose Resolve() returns the raw token
- TestFilteringHandler_StringerPanicNonFatal: confirms recover swallows
the panic and the surrounding record still lands in the buffer
All existing tests still pass — `\b` does not alter the acceptance
behavior of the previously-passing labelled-secret cases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
문제점: 그동안 obs 패키지는 TODO 투성이 stub. 즉
또한 obs.NewHandler 는 어디서도 안 쓰이고 있어서 현재 default slog 가 raw text 만 출력.
변경 — 3가지 layer:
(1) filteringHandler 새 타입 — slog.Handler 인터페이스의 4 메서드 구현:
(2) Redaction regex 두 개 (Python SensitiveFilter 와 1:1 동일):
(sk-|pk-|api|envector_|evt_)[a-zA-Z0-9_-]{10,}
(?i)(token|key|secret|password)["\s:=]+[a-zA-Z0-9_-]{20,}
match 의 첫 8글자만 보존 + *** 추가:
prefix 일부는 grep 가능하게 남겨서 incident 조사 시 매칭 가능.
(3) NewRequestID UUID v4 — crypto/rand 기반 36자 UUID. rand.Read 실패하면 panic 대신 req-anon 으로 fallback (broken host RNG 에서도 서버는 살아있게).
(4) main.go wire: stderr 와 file tee (있을 시) 둘 다 obs.NewHandler 거쳐서 redaction 적용. RUNE_MCP_LOG_FILE 안 셋팅한 production 도 redaction 활성화.
internal/obs/slog.go was full of TODOs that left rune-mcp logging unfiltered: a Vault token in an error message, an envector_ API key in an attr, or a labeled secret in a debug message all flowed through to stderr (and the optional file tee) verbatim. This is a real exposure on dev hosts where users tail ~/.rune/logs/rune-mcp.log over screen share, and a worse one for any future production deploy that ships logs off-host.
Implementation
filteringHandler wraps an inner slog.Handler. Each Record's Message plus every string-typed attr (recursing into KindGroup, best-effort on KindAny w/ fmt.Stringer) is rewritten through two regexes ported 1-for-1 from Python's _SensitiveFilter:
(sk-|pk-|api_|envector_|evt_)[a-zA-Z0-9_-]{10,}
(?i)(token|key|secret|password)["\s:=]+[a-zA-Z0-9_-]{20,}
Replacement keeps the first 8 chars of the match plus "", so prefixes like "evt_AABB" stay grep-able for incident investigation while the body is destroyed. Strings ≤8 chars get the full "***" treatment — preserving prefix would leak the secret.
WithAttrs / WithGroup chain through the inner handler so logger.With( slog.String("api_key", ...)) is also caught.
NewRequestID returns RFC 4122 v4 UUIDs from crypto/rand. Falls back to "req-anon" on rand.Read failure rather than panicking — a working log without per-request correlation beats a crashing server. Format
Wiring
cmd/rune-mcp/main.go now always wraps the chosen TextHandler with obs.NewHandler before SetDefault. Both stderr-only and tee-to-file flows go through redaction; an unset RUNE_MCP_LOG_FILE no longer means "skip filtering."
Tests
Adds slog_test.go covering: each token prefix once, labeled-secret edge cases (JSON quoting, =, " ", short-fail), filteringHandler vs attrs / message / With(), UUID format + uniqueness across 1024 ids