Repository navigation
fix(output): redact sensitive fields under json/pretty formats and debug logging - #95
noamloewenstern wants to merge 6 commits into
Conversation
JSONFormatter and PrettyFormatter marshaled structs directly, ignoring the `sensitive:"true"` struct tag that TableFormatter already honors. Any command using --format json/pretty printed real secrets (instance tokens, SSH private keys, webhook secrets, DB env values, etc.) regardless of --show-sensitive. Add a shared reflection-based redactor (internal/output/redact.go) that deep-walks structs, pointers, slices, and maps, replacing sensitive fields with SensitiveOverlay unless ShowSensitive is set, and wire it into both formatters. TableFormatter's own per-field check now reuses the same isSensitiveField helper instead of duplicating the tag lookup. Struct output is rendered through an order-preserving map so existing JSON key ordering is unaffected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The debug HTTP logger only masked the literal JSON key "token" before printing request/response bodies to stderr. Every other sensitive field (SSH private/public keys, webhook secrets, OAuth client secrets, DB/service env values, server IP/user, etc.) was printed in full whenever --debug was used, independent of --format or --show-sensitive. Extend the key set to cover every JSON field name backing a `sensitive:"true"` struct tag in internal/models and internal/config. This layer decodes arbitrary JSON (request bodies can be untyped maps, not just tagged structs), so it can't walk Go struct tags like internal/output's formatters do — a name-based set is the equivalent for that shape. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…leak `context get` renders the real config.Instance (including its token), unlike `context list` which already had a narrow point-fix stripping Token entirely. It was still fully exposed under --format json/pretty until the internal/output redaction fix landed. Add a regression test so this specific command stays covered. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The reflection-based redactor rebuilt every value it walked, so it also reimplemented parts of encoding/json it never meant to change. Four divergences, each reproduced against json.Marshal of the same value: - []byte rendered as an array of numbers instead of base64. - Maps rendered through the insertion-ordered map used for structs, which exposed Go's randomized map iteration order where encoding/json sorts keys. Live on `server provider <p> regions --format json`, whose models.ProviderOption is a map[string]any: key order changed per run. - Embedded struct fields were nested under the embedded type's name instead of promoted into the parent object, or dropped entirely when the embedded type is unexported. - A type implementing json.Marshaler short-circuited the walk, so a sensitive field reachable only through it was printed in full. - A self-referential value recursed until the goroutine stack overflowed, which is fatal and unrecoverable, where encoding/json returns an error. Fix the shape by not rebuilding what doesn't need rebuilding: walk only types that can contain a `sensitive:"true"` field (containsSensitive, memoized) and hand everything else back untouched for encoding/json to marshal natively. That makes non-sensitive output identical by construction rather than by reimplementation, and it removes the need for the json.Marshaler special case, since types with no sensitive fields are already passed through. Within a walked subtree: promote embedded struct fields, render maps as plain maps so encoding/json sorts their keys, redact through a custom MarshalJSON rather than deferring to it, and guard pointer cycles. Note that SensitiveOverlay is a string, so a redacted non-string field (models.Server.Port) renders as a JSON string. Documented on redactSensitive; --show-sensitive returns the correctly typed value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
models.Database carries ten password fields (postgres, mysql, mariadb,
mongo, redis, keydb, clickhouse, dragonfly) that had `table:"-"` but no
`sensitive:"true"`. The tag-driven redaction added for json/pretty output
cannot see an untagged field, so `database get`/`database list` printed
them in plaintext under --format json and --format pretty:
{"uuid":"...","name":"db","postgres_password":"<real password>"}
They were likewise absent from the debug logger's key set, so --debug
printed them too. Tag all ten and add their JSON names to
sensitiveDebugLogKeys.
Only the Database model is tagged here; the create/update request types
carry the same field names and are covered on the debug path by the key
set, which is the only path that renders them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y set sensitiveDebugLogKeys is a hand-maintained list of JSON field names with no automated link to the `sensitive:"true"` tags it mirrors. The debug logger works on untyped JSON bodies, so it cannot discover new tags on its own: a tag added to a model next month would silently keep being logged in full, with nothing failing. Parse internal/models and internal/config, collect every tagged field's JSON name, and assert the set covers them. Verified by deleting one key and watching it name the exact field: Database.RedisPassword is tagged sensitive:"true" but its JSON name "redis_password" is missing from sensitiveDebugLogKeys in client.go Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed three follow-up commits after re-reviewing my own work adversarially — building each edge case as a failing test rather than reasoning about it. The PR description is updated to match. Summary of what that found: The redactor was reshaping output it only meant to pass through. By rebuilding every value it walked, it also reimplemented parts of
The fix was to stop rebuilding what doesn't need rebuilding: walk only types that can contain a
The debug-log key set had no automated link to the tags it mirrors. A One deliberate non-fix, documented in the PR body: Full suite still green except the pre-existing |
|
Any update? |
Summary
Fixes #94
--format jsonand--format prettymarshal structs directly withencoding/json, which has no concept of thesensitive:"true"struct tag. OnlyTableFormatterchecked it. So any command that hides secrets in table output printed them in full under--format jsonor--format pretty, regardless of--show-sensitive. This affects everysensitive:"true"field ininternal/models/internal/config— instance tokens, SSH private/public keys, webhook secrets, OAuth client secrets, DB/service env values, server IP/user, team email, etc. — not just the two fields already patched piecemeal (cmd/context/list.go,cmd/cloudtoken,cmd/s3,cmd/cloudiniteach carry their own hand-rolled per-command redaction for their one known field; every other command was unprotected).--debuglogged full request/response bodies with only the literal JSON key"token"masked — every other sensitive field above was printed to stderr whenever--debugwas used, independent of--format/--show-sensitiveentirely.models.Databasecarries ten database passwords that were never taggedsensitive:"true"at all, so they leaked through both paths above.Changes
internal/output/redact.go(new): a reflection-based redactor that replaces anysensitive:"true"field with the existingSensitiveOverlayunlessShowSensitiveis set. Wired intoJSONFormatter.FormatandPrettyFormatter.Format.TableFormatternow reuses the sameisSensitiveFieldtag check instead of a third copy of the same lookup. The original value is never mutated.It walks only types that can contain a sensitive field (
containsSensitive, memoized) and hands everything else back untouched forencoding/jsonto marshal natively. That is what keeps non-sensitive output identical to marshaling the original value — by construction, rather than by reimplementingencoding/json's rules. Within a walked subtree it preserves struct field order, promotes embedded struct fields into the parent object, renders maps as plain maps soencoding/jsonstill sorts their keys, redacts through a customMarshalJSONrather than deferring to it, and guards pointer cycles so a self-referential value cannot overflow the stack.internal/models/database.go: tag the tenmodels.Databasepassword fields (postgres, mysql, mariadb, mongo, redis, keydb, clickhouse, dragonfly)sensitive:"true". They hadtable:"-"but no sensitive tag, so tag-driven redaction could not see them anddatabase get/database list --format jsonprinted them in plaintext.internal/api/client.go: the debug logger's redaction key set is extended from just"token"to every JSON field name backing asensitive:"true"tag, including the ten database passwords. This layer works on decoded, untyped JSON (request bodies can be arbitrary maps, not just tagged structs), so it can't walk Go struct tags directly like the formatters do — a name-based set is the equivalent for that shape.internal/api/sensitive_keys_test.go(new): that key set is hand-maintained with no automated link to the tags it mirrors, so a tag added later would silently keep being logged in full. This test parsesinternal/modelsandinternal/config, collects every tagged field's JSON name, and fails if the set doesn't cover it.cmd/context/get_test.go(new):context getrenders the realconfig.Instance(unlikecontext list, which already had its own narrow fix), and was still fully exposed under--format json/prettyuntil this PR. Added regression coverage.Behavior note
SensitiveOverlayis a string, so a redacted field of a non-string type renders as a JSON string. The one field this affects today ismodels.Server.Port, which becomes"port":"********"instead of a number when hidden.--show-sensitivereturns the correctly typed value. Documented onredactSensitive.Testing
internal/output/redact_test.go: sensitive fields at top level, behind pointers, nested, in slices of structs, and in maps;models.PrivateKey,models.Database, andconfig.Instance; across all three formatters, bothShowSensitivestates. Plus shape guards — output byte-identical toencoding/jsonfor everything but the redacted field (base64[]byte,time.Time, everyomitemptyflavour, nil vs empty slices/maps), stable map key ordering across repeated renders, embedded field promotion, redaction through a customMarshalJSON, and a self-referential value that must not overflow the stack.internal/api/client_test.go: extends the existing debug-redaction tests with private-key material and webhook-secret cases.internal/api/sensitive_keys_test.go: verified by deleting one key and confirming it names the exact field (Database.RedisPassword ... "redis_password" is missing from sensitiveDebugLogKeys).cmd/context/get_test.go: same red/green treatment forcontext get.Test plan
go build ./...go test ./internal/output/... ./internal/api/... ./cmd/context/...— all pass, including every pre-existing test in these packagesgo test ./...— all pass exceptTestFirewallServiceUnit_GoldenFixture_TwoNamespacesininternal/wireguard, which fails identically on an unmodifiedmainworktree (line-ending golden-fixture mismatch on Windows) — pre-existing and unrelatedgo vet ./...— cleangolangci-lint run ./internal/output/... ./internal/api/...— no new findings from this change; the pre-existinggci(formatter.go, formatter_test.go, table.go, error.go, options.go) andgovetreflect.Ptr→reflect.Pointer(table.go, 3 lines) findings are present identically on unmodifiedmainand are left untouched here to keep this diff scoped to the security fix--format json/--format prettyand--debugnow hide sensitive fields by default and reveal them with--show-sensitive(formatter output only —--debugredaction is unconditional, since it's a diagnostic trace rather than a deliberate request to reveal a value)Notes for maintainers
cmd/cloudtoken/cloudtoken.go,cmd/s3/s3.go, andcmd/cloudinit/cloudinit.goeach carry a hand-rolledprepareOutput/formatredaction for their own one known sensitive field — these predate this fix and are now redundant (harmless double-redaction) rather than broken. Left untouched to keep this diff scoped; happy to follow up with a cleanup PR.message/errorkey,client.gosurfaces the raw response body verbatim to the user; and deployment logs (internal/service/deployment.go) are returned as a raw string without passing throughinternal/output. Happy to open issues for either if useful.value,key,user,ip,port,email) because those are the JSON names of tagged fields. This does over-redact unrelated fields sharing those names in--debugoutput — notablykeyon environment variables, which is the variable's name rather than its value. That seemed the right trade for a diagnostic path, but happy to narrow it if you'd prefer.🤖 Generated with Claude Code