Skip to content

feat(obs): wire redacting slog handler into rune-mcp#126

Merged
couragehong merged 2 commits into
feat/go-migrationfrom
couragehong/feat/log-redaction
May 8, 2026
Merged

feat(obs): wire redacting slog handler into rune-mcp#126
couragehong merged 2 commits into
feat/go-migrationfrom
couragehong/feat/log-redaction

Conversation

@couragehong
Copy link
Copy Markdown
Contributor

문제점: 그동안 obs 패키지는 TODO 투성이 stub. 즉

  • Vault 토큰이 error message 에 그대로 노출
  • envector API key 가 attr 으로 stderr 직출
  • ~/.rune/logs/rune-mcp.log 도 동일 — dev 가 화면공유로 tail 하면 그대로 새는 구조

또한 obs.NewHandler 는 어디서도 안 쓰이고 있어서 현재 default slog 가 raw text 만 출력.

변경 — 3가지 layer:

(1) filteringHandler 새 타입 — slog.Handler 인터페이스의 4 메서드 구현:

  • Handle: Record 의 Message + 모든 string-typed attr 을 redact 통과시킴
  • WithAttrs: logger.With(slog.String("api_key", ...)) 도 redact
  • WithGroup: 중첩 group attr 도 redact (재귀)
  • Enabled: inner handler 에 위임

(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글자만 보존 + *** 추가:

  • evt_AABBCCDD11223344 → evt_AABB***
  • token: ABCDEFGHIJKLMNOP... → token: A***

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

  • 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.

couragehong and others added 2 commits May 8, 2026 10:23
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>
@couragehong couragehong self-assigned this May 8, 2026
@couragehong couragehong merged commit 9bbbdda into feat/go-migration May 8, 2026
1 check passed
@couragehong couragehong deleted the couragehong/feat/log-redaction branch May 8, 2026 01:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant