Skip to content

fix(output): redact sensitive fields under json/pretty formats and debug logging - #95

Open
noamloewenstern wants to merge 6 commits into
coollabsio:mainfrom
noamloewenstern:fix/sensitive-field-redaction-json-pretty
Open

noamloewenstern wants to merge 6 commits into
coollabsio:mainfrom
noamloewenstern:fix/sensitive-field-redaction-json-pretty

Conversation

@noamloewenstern

@noamloewenstern noamloewenstern commented Sep 17, 2026 •

Copy link
Copy Markdown

Summary

Fixes #94

  • --format json and --format pretty marshal structs directly with encoding/json, which has no concept of the sensitive:"true" struct tag. Only TableFormatter checked it. So any command that hides secrets in table output printed them in full under --format json or --format pretty, regardless of --show-sensitive. This affects every sensitive:"true" field in internal/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/cloudinit each carry their own hand-rolled per-command redaction for their one known field; every other command was unprotected).
  • Separately, --debug logged full request/response bodies with only the literal JSON key "token" masked — every other sensitive field above was printed to stderr whenever --debug was used, independent of --format/--show-sensitive entirely.
  • models.Database carries ten database passwords that were never tagged sensitive:"true" at all, so they leaked through both paths above.
  • This already leaked a live API token and, separately, private key material into a transcript via exactly this path — the motivating case is CLI output being safe to hand to an AI agent or paste into a bug report without manually scrubbing secrets first.

Changes

  1. internal/output/redact.go (new): a reflection-based redactor that replaces any sensitive:"true" field with the existing SensitiveOverlay unless ShowSensitive is set. Wired into JSONFormatter.Format and PrettyFormatter.Format. TableFormatter now reuses the same isSensitiveField tag 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 for encoding/json to marshal natively. That is what keeps non-sensitive output identical to marshaling the original value — by construction, rather than by reimplementing encoding/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 so encoding/json still sorts their keys, redacts through a custom MarshalJSON rather than deferring to it, and guards pointer cycles so a self-referential value cannot overflow the stack.

  2. internal/models/database.go: tag the ten models.Database password fields (postgres, mysql, mariadb, mongo, redis, keydb, clickhouse, dragonfly) sensitive:"true". They had table:"-" but no sensitive tag, so tag-driven redaction could not see them and database get/database list --format json printed them in plaintext.

  3. internal/api/client.go: the debug logger's redaction key set is extended from just "token" to every JSON field name backing a sensitive:"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.

  4. 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 parses internal/models and internal/config, collects every tagged field's JSON name, and fails if the set doesn't cover it.

  5. cmd/context/get_test.go (new): context get renders the real config.Instance (unlike context list, which already had its own narrow fix), and was still fully exposed under --format json/pretty until this PR. Added regression coverage.

Behavior note

SensitiveOverlay is a string, so a redacted field of a non-string type renders as a JSON string. The one field this affects today is models.Server.Port, which becomes "port":"********" instead of a number when hidden. --show-sensitive returns the correctly typed value. Documented on redactSensitive.

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, and config.Instance; across all three formatters, both ShowSensitive states. Plus shape guards — output byte-identical to encoding/json for everything but the redacted field (base64 []byte, time.Time, every omitempty flavour, nil vs empty slices/maps), stable map key ordering across repeated renders, embedded field promotion, redaction through a custom MarshalJSON, 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 for context get.
  • Every new assertion was verified red against the pre-fix code and green after, by reverting and restoring the relevant files locally rather than trusting the diff alone.

Test plan

  • go build ./...
  • go test ./internal/output/... ./internal/api/... ./cmd/context/... — all pass, including every pre-existing test in these packages
  • go test ./... — all pass except TestFirewallServiceUnit_GoldenFixture_TwoNamespaces in internal/wireguard, which fails identically on an unmodified main worktree (line-ending golden-fixture mismatch on Windows) — pre-existing and unrelated
  • go vet ./... — clean
  • golangci-lint run ./internal/output/... ./internal/api/... — no new findings from this change; the pre-existing gci (formatter.go, formatter_test.go, table.go, error.go, options.go) and govet reflect.Ptr→reflect.Pointer (table.go, 3 lines) findings are present identically on unmodified main and are left untouched here to keep this diff scoped to the security fix
  • Manual verification with synthetic dummy secrets (no real credentials used anywhere) confirms --format json/--format pretty and --debug now hide sensitive fields by default and reveal them with --show-sensitive (formatter output only — --debug redaction 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, and cmd/cloudinit/cloudinit.go each carry a hand-rolled prepareOutput/format redaction 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.
  • Two adjacent exposure paths exist that this PR deliberately does not touch, since both predate it and neither is part of the formatter/debug-log fix: on a non-2xx response with no message/error key, client.go surfaces the raw response body verbatim to the user; and deployment logs (internal/service/deployment.go) are returned as a raw string without passing through internal/output. Happy to open issues for either if useful.
  • The debug-log key set includes some generic names (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 --debug output — notably key on 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

noamloewenstern and others added 6 commits September 17, 2026 22:40
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>
@noamloewenstern

Copy link
Copy Markdown
Author

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 encoding/json it never intended 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 container meant for structs, which exposed Go's randomized map iteration order where encoding/json sorts keys. This was live on server provider <provider> regions --format json — models.ProviderOption is a map[string]any, so key order changed on every 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. Latent today (the ApplicationSettingsRequest embeds are request-only), but a silent shape change waiting for the first output type that gains one.
  • A type implementing json.Marshaler short-circuited the walk, so a sensitive field reachable only through it would print in full.
  • A self-referential value recursed until the goroutine stack overflowed — fatal and unrecoverable, where encoding/json returns a clean error.

The fix was to stop rebuilding what doesn't need rebuilding: walk only types that can contain a sensitive:"true" field and hand everything else back untouched for encoding/json to marshal natively. Non-sensitive output is now identical by construction rather than by reimplementation, and the json.Marshaler special case disappeared along the way.

models.Database's ten passwords were never tagged sensitive, so tag-driven redaction couldn't see them — database get --format json printed postgres_password and friends in plaintext, and --debug logged them too. Tagged, and their JSON names added to the debug key set.

The debug-log key set had no automated link to the tags it mirrors. A sensitive:"true" tag added next month would silently keep being logged in full with nothing failing. Added a test that parses internal/models and internal/config and fails naming the exact field if the set doesn't cover it.

One deliberate non-fix, documented in the PR body: SensitiveOverlay is a string, so models.Server.Port renders as "port":"********" rather than a number when hidden. That's inherent to a string overlay on an int field; flagging it as a JSON shape change anyone parsing that field should know about, rather than papering over it.

Full suite still green except the pre-existing internal/wireguard golden-fixture failure, which I confirmed fails identically on a clean main worktree. go vet clean.

@noamloewenstern

Copy link
Copy Markdown
Author

Any update?

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.

CLI prints the API token (and other secrets) in full under --format json/pretty and --debug, regardless of --show-sensitive

1 participant