From acc4c10843861bc1bddd154c2c4a8ac847f9e3b3 Mon Sep 17 00:00:00 2001 From: Jordan English <6087717+jordanenglish@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:16:49 -0400 Subject: [PATCH 1/3] Redact sensitive values from command output Some API responses carry credentials and tfctl prints them. A created token is returned once in full, and a state version carries download URLs that grant the state itself without a token. AGENTS.md already requires that a displayer payload never include credentials, noting that JSON output serializes the full payload rather than the displayed fields. JSONAPIDisplayer cannot honor that, because it carries whatever the server returned, and ExcludeColumns does not help: it feeds FieldTemplates for table and pretty output while outputJSON marshals the raw envelope. Add internal/pkg/redact and apply it in format.Outputter.Display, before the format is selected, so every format agrees and a --jq filter cannot reach a value that --json would have hidden. Cover Outputter.CopyRaw for bodies no displayer handles, such as plan JSON, and the --dry-run request preview, which otherwise echoes the value being set. Masking is controlled by the redact profile property, TFCTL_REDACT, or --no-redact, with modes strict (default), known, and off. Copying is on write, so a response with nothing to mask costs no allocation. --- .../unreleased/BUG FIXES-20260806-170000.yaml | 3 + .../ENHANCEMENTS-20260806-170001.yaml | 3 + .../unreleased/NOTES-20260806-170002.yaml | 3 + README.md | 16 + assets/tfctl.png | Bin 335102 -> 356573 bytes cmd/tfctl/main.go | 21 +- internal/commands/api/api.go | 53 +- internal/commands/api/api_test.go | 2 +- internal/commands/api/redact_test.go | 246 ++++++++ internal/commands/profile/property_docs.go | 6 + internal/pkg/cmd/invocation.go | 46 ++ internal/pkg/cmd/redact_test.go | 120 ++++ internal/pkg/format/jsonapi.go | 38 +- internal/pkg/format/output.go | 149 ++++- internal/pkg/format/redact_corpus_test.go | 322 ++++++++++ internal/pkg/format/redact_test.go | 256 ++++++++ internal/pkg/profile/profile.go | 25 +- internal/pkg/profile/profile_test.go | 9 +- internal/pkg/redact/redact.go | 556 ++++++++++++++++++ internal/pkg/redact/redact_test.go | 401 +++++++++++++ 20 files changed, 2248 insertions(+), 27 deletions(-) create mode 100644 .changes/unreleased/BUG FIXES-20260806-170000.yaml create mode 100644 .changes/unreleased/ENHANCEMENTS-20260806-170001.yaml create mode 100644 .changes/unreleased/NOTES-20260806-170002.yaml create mode 100644 internal/commands/api/redact_test.go create mode 100644 internal/pkg/cmd/redact_test.go create mode 100644 internal/pkg/format/redact_corpus_test.go create mode 100644 internal/pkg/format/redact_test.go create mode 100644 internal/pkg/redact/redact.go create mode 100644 internal/pkg/redact/redact_test.go diff --git a/.changes/unreleased/BUG FIXES-20260806-170000.yaml b/.changes/unreleased/BUG FIXES-20260806-170000.yaml new file mode 100644 index 0000000..db0c0cf --- /dev/null +++ b/.changes/unreleased/BUG FIXES-20260806-170000.yaml @@ -0,0 +1,3 @@ +kind: BUG FIXES +body: "Credentials returned by the API are no longer printed. A created token, and the `hosted-state-download-url` and `hosted-json-state-download-url` of a state version, which grant access to state without a token, were rendered in every output format including `--json` and `--jq`. `--dry-run` echoed the request body and headers, so previewing a sensitive variable printed the value being set" +time: 2026-08-06T17:00:00.000000-04:00 diff --git a/.changes/unreleased/ENHANCEMENTS-20260806-170001.yaml b/.changes/unreleased/ENHANCEMENTS-20260806-170001.yaml new file mode 100644 index 0000000..b536b3a --- /dev/null +++ b/.changes/unreleased/ENHANCEMENTS-20260806-170001.yaml @@ -0,0 +1,3 @@ +kind: ENHANCEMENTS +body: "Added a `redact` profile property and a `TFCTL_REDACT` environment variable to control masking of sensitive values in output, with modes `strict` (the default), `known`, and `off`, plus a `--no-redact` global flag to show masked values for a single command" +time: 2026-08-06T17:00:01.000000-04:00 diff --git a/.changes/unreleased/NOTES-20260806-170002.yaml b/.changes/unreleased/NOTES-20260806-170002.yaml new file mode 100644 index 0000000..8f0682f --- /dev/null +++ b/.changes/unreleased/NOTES-20260806-170002.yaml @@ -0,0 +1,3 @@ +kind: NOTES +body: 'Sensitive values in command output are now masked by default. A script that reads a state version download URL, or a newly created token, out of `tfctl` output will see `(redacted)` until it passes `--no-redact` or sets `redact = "off"` in its profile' +time: 2026-08-06T17:00:02.000000-04:00 diff --git a/README.md b/README.md index 6214ef1..acd1d44 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,8 @@ If you have **not** configured a particular option for the active profile, `tfct `TFCTL_SKIP_MIGRATE`: Don't migrate installed skill files to the latest version (if contents are known to be installed by a previous version). +`TFCTL_REDACT`: Control masking of sensitive values in output. Accepts `strict` (the default), `known`, or `off`. Refer to [Output redaction](#output-redaction). + `CHECKPOINT_DISABLE`: Don't check for newer versions of tfctl. ## Command reference @@ -211,12 +213,26 @@ The `tfctl` command can manage HCP Terraform runs and variables with the corresp - `--no-color`: Disables color output. +- `--no-redact`: Shows sensitive values in output instead of masking them. Refer to [Output redaction](#output-redaction). + - `--profile=`: The profile to use. If omitted, the CLI uses the current profile. - `--quiet`: Minimizes output, rendering only essential content. - `--version`: Print the version of `tfctl` CLI. +### Output redaction + +Some API responses carry credentials. A created token is returned once in full, and a state version carries signed download URLs that grant access to the state, which contains every value Terraform wrote. `tfctl` masks these values in all output formats, including `--json` and `--jq`, and reports which fields it masked. Masking applies to the response body only. It is not an access control boundary: the API decides what your token can read, and redaction limits what a permitted response leaves behind in a terminal, a log, or an automated caller. + +Set the mode with the `redact` profile property or the `TFCTL_REDACT` environment variable: + +- `strict` (the default): masks known secret fields, values the API declares sensitive, and values whose name or shape indicates a credential. + +- `known`: masks only known secret fields and values the API declares sensitive. Use this mode when a name or shape heuristic hides a value you need. + +- `off`: disables masking. The `--no-redact` flag does the same for one command. + ### Exit Codes | Exit | Meaning | Solution | diff --git a/assets/tfctl.png b/assets/tfctl.png index 3ee26d26aa1d5a3cfafd2fa669771e17af1f20ec..43584b29c8405e2af84d1fae3bcd8795d8b9f61f 100644 GIT binary patch delta 75895 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 J%MP8n{69yL*E9eC delta 55105 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 TrcVU=11(LRgU=8Ae*HfHq4JN~ diff --git a/cmd/tfctl/main.go b/cmd/tfctl/main.go index 521481a..f409b33 100644 --- a/cmd/tfctl/main.go +++ b/cmd/tfctl/main.go @@ -188,16 +188,17 @@ func realMain() int { Autocomplete: true, AutocompleteNoDefaultFlags: true, AutocompleteGlobalFlags: map[string]complete.Predictor{ - "--help": complete.PredictNothing, - "--version": complete.PredictNothing, - "--debug": complete.PredictAnything, - "--jq": complete.PredictAnything, - "--json": complete.PredictAnything, - "--markdown": complete.PredictAnything, - "--no-color": complete.PredictAnything, - "--profile": profiles.PredictProfiles(false, true), - "--quiet": complete.PredictAnything, - "--dry-run": complete.PredictAnything, + "--help": complete.PredictNothing, + "--version": complete.PredictNothing, + "--debug": complete.PredictAnything, + "--jq": complete.PredictAnything, + "--json": complete.PredictAnything, + "--markdown": complete.PredictAnything, + "--no-color": complete.PredictAnything, + "--no-redact": complete.PredictAnything, + "--profile": profiles.PredictProfiles(false, true), + "--quiet": complete.PredictAnything, + "--dry-run": complete.PredictAnything, }, } diff --git a/internal/commands/api/api.go b/internal/commands/api/api.go index 7cd6b35..1125e95 100644 --- a/internal/commands/api/api.go +++ b/internal/commands/api/api.go @@ -31,6 +31,7 @@ import ( "github.com/hashicorp/tfctl-cli/internal/pkg/iostreams" "github.com/hashicorp/tfctl-cli/internal/pkg/logging" "github.com/hashicorp/tfctl-cli/internal/pkg/openapi" + "github.com/hashicorp/tfctl-cli/internal/pkg/redact" terraformcfg "github.com/hashicorp/tfctl-cli/internal/pkg/terraform" "github.com/hashicorp/tfctl-cli/version" ) @@ -507,7 +508,8 @@ func RunAPI(ctx context.Context, opts *Opts) error { // In dry-run mode, skip mutating requests and report what would have happened. if opts.DryRun && isMutationMethod(method) { fmt.Fprintf(opts.IO.Err(), "%s would send %s request\n", opts.IO.ColorScheme().DryRunLabel(), method) - writeDryRunRequest(opts.IO.Err(), method, opts.URL, requestHeaders, body) + writeDryRunRequest(opts.IO.Err(), method, opts.URL, requestHeaders, body, opts.Output.Redactor()) + opts.Output.ReportRedactions() return nil } @@ -553,7 +555,11 @@ func RunAPI(ctx context.Context, opts *Opts) error { if !strings.HasPrefix(response.Header.Get("Content-Type"), "application/vnd.api+json") { logger.Debug("Response body was not application/vnd.api+json, rendering raw body") - _, _ = io.Copy(opts.IO.Out(), response.Body) + // A raw body still needs masking. Plan JSON output, for example, holds + // every value Terraform wrote, including sensitive ones. + if err := opts.Output.CopyRaw(response.Body, response.Header.Get("Content-Type")); err != nil { + logger.Debug("Failed to render raw body", "error", err) + } return nil } @@ -793,25 +799,60 @@ func isMutationMethod(method string) bool { } } -func writeDryRunRequest(w io.Writer, method string, u *url.URL, headers http.Header, body []byte) { +// writeDryRunRequest reports the request that would have been sent. +// +// The request is masked with the same rules as a response. A dry run is what a +// careful person does before setting a sensitive variable, so this is the moment +// a secret is most likely to be written to a terminal, and the value being set +// is the secret itself. +func writeDryRunRequest(w io.Writer, method string, u *url.URL, headers http.Header, body []byte, redactor *redact.Redactor) { fmt.Fprintf(w, "> %s %s\n", method, u.String()) + keys := make([]string, 0, len(headers)) for key := range headers { keys = append(keys, key) } sort.Strings(keys) + for _, key := range keys { - fmt.Fprintf(w, "> %s: %s\n", key, strings.Join(headers.Values(key), ", ")) + value := strings.Join(headers.Values(key), ", ") + if masked, ok := redactor.MaskHeader(key, value); ok { + value = masked + } + fmt.Fprintf(w, "> %s: %s\n", key, value) } + if len(body) == 0 { return } + fmt.Fprintln(w) - _, _ = w.Write(formatDryRunBody(body)) + _, _ = w.Write(formatDryRunBody(body, redactor)) fmt.Fprintln(w) } -func formatDryRunBody(body []byte) []byte { +// formatDryRunBody indents the request body and masks any sensitive value in it. +// +// A body that cannot be parsed cannot be masked, so it is withheld rather than +// printed. Every body this command sends is JSON that it built or that the user +// supplied with --input, so an unparseable body is already a request that would +// fail. +func formatDryRunBody(body []byte, redactor *redact.Redactor) []byte { + if redactor.Enabled() { + var decoded any + if err := json.Unmarshal(body, &decoded); err != nil { + return []byte("(body withheld: it is not valid JSON, so it cannot be masked. Use --no-redact to show it)") + } + + before := redactor.Count() + masked := redactor.Apply(decoded) + if redactor.Count() != before { + if formatted, err := json.MarshalIndent(masked, "", " "); err == nil { + return formatted + } + } + } + var formatted bytes.Buffer if err := json.Indent(&formatted, body, "", " "); err == nil { return formatted.Bytes() diff --git a/internal/commands/api/api_test.go b/internal/commands/api/api_test.go index 1372884..d72f219 100644 --- a/internal/commands/api/api_test.go +++ b/internal/commands/api/api_test.go @@ -1067,7 +1067,7 @@ func TestWriteDryRunRequest(t *testing.T) { } body := []byte(`{"data":{"type":"projects"}}`) - writeDryRunRequest(io.Err(), http.MethodPost, u, headers, body) + writeDryRunRequest(io.Err(), http.MethodPost, u, headers, body, nil) output := io.Error.String() if !strings.Contains(output, "> POST https://example.com/api/v2/projects") { diff --git a/internal/commands/api/redact_test.go b/internal/commands/api/redact_test.go new file mode 100644 index 0000000..fabfdc9 --- /dev/null +++ b/internal/commands/api/redact_test.go @@ -0,0 +1,246 @@ +// Copyright IBM Corp. 2026 +// SPDX-License-Identifier: MPL-2.0 + +package api + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfctl-cli/internal/pkg/iostreams" + "github.com/hashicorp/tfctl-cli/internal/pkg/redact" +) + +// These tests drive the api command against a fake Terraform Enterprise so that +// the command path is covered, not only the redactor. The credentials are +// invented: a signed URL with a fake signature, and a token that says it is not +// real. +const ( + fakeStateDownloadURL = "https://archivist.terraform.io/v1/object/EXAMPLE?X-Amz-Signature=notarealsignature" + fakeCreatedToken = "EXAMPLEnotreal.atlasv1.notarealtokenvaluenotarealtokenvaluenotarealtoken00" +) + +func TestRunAPI_MasksSensitiveValuesByDefault(t *testing.T) { + t.Parallel() + + server, _ := newAPITestServer(map[string]http.HandlerFunc{ + "GET /api/v2/state-versions/sv-EXAMPLE": func(w http.ResponseWriter, _ *http.Request) { + writeJSONAPIResponse(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "id": "sv-EXAMPLE", + "type": "state-versions", + "attributes": map[string]any{ + "serial": 42, + "status": "finalized", + "hosted-state-download-url": fakeStateDownloadURL, + }, + }, + }) + }, + }) + defer server.Close() + + io := iostreams.Test() + opts := newTestOpts(t, server.URL, io, func(opts *Opts) { + opts.URL = mustResolveTestURL(t, opts.Client.BaseURL.String(), "/state-versions/sv-EXAMPLE") + }) + opts.Output.SetRedactor(redact.New(redact.ModeStrict)) + + require.NoError(t, RunAPI(context.Background(), opts)) + + require.NotContains(t, io.Output.String(), fakeStateDownloadURL) + require.NotContains(t, io.Output.String(), "notarealsignature") + require.Contains(t, io.Output.String(), redact.Placeholder) + require.Contains(t, io.Output.String(), "finalized") + require.Contains(t, io.Error.String(), "hosted-state-download-url") +} + +func TestRunAPI_NoRedactShowsSensitiveValues(t *testing.T) { + t.Parallel() + + server, _ := newAPITestServer(map[string]http.HandlerFunc{ + "POST /api/v2/organizations/example/authentication-tokens": func(w http.ResponseWriter, _ *http.Request) { + writeJSONAPIResponse(w, http.StatusCreated, map[string]any{ + "data": map[string]any{ + "id": "at-EXAMPLE", + "type": "authentication-tokens", + "attributes": map[string]any{ + "description": "ci runner", + "token": fakeCreatedToken, + }, + }, + }) + }, + }) + defer server.Close() + + // A token is returned once. Masking it by default is right, and being unable + // to retrieve it at all would make the command useless, so the escape hatch + // has to work. + io := iostreams.Test() + opts := newTestOpts(t, server.URL, io, func(opts *Opts) { + opts.URL = mustResolveTestURL(t, opts.Client.BaseURL.String(), "/organizations/example/authentication-tokens") + opts.Method = http.MethodPost + }) + opts.Output.SetRedactor(redact.New(redact.ModeOff)) + + require.NoError(t, RunAPI(context.Background(), opts)) + require.Contains(t, io.Output.String(), fakeCreatedToken) +} + +func TestRunAPI_MasksCreatedToken(t *testing.T) { + t.Parallel() + + server, _ := newAPITestServer(map[string]http.HandlerFunc{ + "POST /api/v2/organizations/example/authentication-tokens": func(w http.ResponseWriter, _ *http.Request) { + writeJSONAPIResponse(w, http.StatusCreated, map[string]any{ + "data": map[string]any{ + "id": "at-EXAMPLE", + "type": "authentication-tokens", + "attributes": map[string]any{ + "description": "ci runner", + "token": fakeCreatedToken, + }, + }, + }) + }, + }) + defer server.Close() + + io := iostreams.Test() + opts := newTestOpts(t, server.URL, io, func(opts *Opts) { + opts.URL = mustResolveTestURL(t, opts.Client.BaseURL.String(), "/organizations/example/authentication-tokens") + opts.Method = http.MethodPost + }) + opts.Output.SetRedactor(redact.New(redact.ModeStrict)) + + require.NoError(t, RunAPI(context.Background(), opts)) + require.NotContains(t, io.Output.String(), fakeCreatedToken) + require.Contains(t, io.Output.String(), "ci runner") +} + +func TestWriteDryRunRequest_MasksTheRequestBody(t *testing.T) { + t.Parallel() + + // A dry run is what a careful person does before setting a sensitive + // variable, so the value being previewed is the secret itself. + server, _ := newAPITestServer(map[string]http.HandlerFunc{}) + defer server.Close() + + io := iostreams.Test() + opts := newTestOpts(t, server.URL, io, func(opts *Opts) { + opts.URL = mustResolveTestURL(t, opts.Client.BaseURL.String(), "/workspaces/ws-EXAMPLE/vars") + opts.Method = http.MethodPost + opts.DryRun = true + opts.Attributes = map[string]string{ + "key": "db_password", + "value": "notarealpassword-EXAMPLE", + "sensitive": "true", + } + }) + opts.Output.SetRedactor(redact.New(redact.ModeStrict)) + + require.NoError(t, RunAPI(context.Background(), opts)) + + report := io.Error.String() + require.Contains(t, report, "would send POST request") + require.NotContains(t, report, "notarealpassword-EXAMPLE") + require.Contains(t, report, redact.Placeholder) + // The rest of the preview still has to be useful. + require.Contains(t, report, "db_password") + require.Contains(t, report, "/workspaces/ws-EXAMPLE/vars") + require.Contains(t, report, "--no-redact") +} + +func TestWriteDryRunRequest_MasksASensitiveHeader(t *testing.T) { + t.Parallel() + + server, _ := newAPITestServer(map[string]http.HandlerFunc{}) + defer server.Close() + + io := iostreams.Test() + opts := newTestOpts(t, server.URL, io, func(opts *Opts) { + opts.URL = mustResolveTestURL(t, opts.Client.BaseURL.String(), "/workspaces/ws-EXAMPLE/vars") + opts.Method = http.MethodPost + opts.DryRun = true + opts.Attributes = map[string]string{"key": "harmless"} + opts.Headers = []string{ + "Authorization: Bearer notarealtokenvalue-EXAMPLE", + "X-Api-Key: notarealapikey-EXAMPLE", + "X-Request-Id: keep-me", + } + }) + opts.Output.SetRedactor(redact.New(redact.ModeStrict)) + + require.NoError(t, RunAPI(context.Background(), opts)) + + report := io.Error.String() + require.NotContains(t, report, "notarealtokenvalue-EXAMPLE") + require.NotContains(t, report, "notarealapikey-EXAMPLE") + require.Contains(t, report, "keep-me") +} + +func TestWriteDryRunRequest_NoRedactShowsTheBody(t *testing.T) { + t.Parallel() + + server, _ := newAPITestServer(map[string]http.HandlerFunc{}) + defer server.Close() + + io := iostreams.Test() + opts := newTestOpts(t, server.URL, io, func(opts *Opts) { + opts.URL = mustResolveTestURL(t, opts.Client.BaseURL.String(), "/workspaces/ws-EXAMPLE/vars") + opts.Method = http.MethodPost + opts.DryRun = true + opts.Attributes = map[string]string{"value": "notarealpassword-EXAMPLE", "key": "db_password"} + }) + opts.Output.SetRedactor(redact.New(redact.ModeOff)) + + require.NoError(t, RunAPI(context.Background(), opts)) + require.Contains(t, io.Error.String(), "notarealpassword-EXAMPLE") +} + +func TestFormatDryRunBody_WithholdsABodyItCannotParse(t *testing.T) { + t.Parallel() + + // An unparseable body cannot be masked, so it must not be printed. Every body + // this command sends is JSON, so this is already a request that would fail. + body := []byte(`{"data": notjson notarealpassword-EXAMPLE`) + + withheld := formatDryRunBody(body, redact.New(redact.ModeStrict)) + require.NotContains(t, string(withheld), "notarealpassword-EXAMPLE") + require.Contains(t, string(withheld), "--no-redact") + + shown := formatDryRunBody(body, redact.New(redact.ModeOff)) + require.Contains(t, string(shown), "notarealpassword-EXAMPLE") +} + +func TestRunAPI_MasksRawJSONBody(t *testing.T) { + t.Parallel() + + // Plan JSON output is not a JSON:API envelope, so no displayer handles it. It + // holds every value Terraform wrote. + const planJSON = `{"format_version":"1.2","variables":{"db_password":{"value":"notarealpassword-EXAMPLE"},"region":{"value":"us-east-1"}}}` + + server, _ := newAPITestServer(map[string]http.HandlerFunc{ + "GET /api/v2/plans/plan-EXAMPLE/json-output": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(planJSON)) + }, + }) + defer server.Close() + + io := iostreams.Test() + opts := newTestOpts(t, server.URL, io, func(opts *Opts) { + opts.URL = mustResolveTestURL(t, opts.Client.BaseURL.String(), "/plans/plan-EXAMPLE/json-output") + }) + opts.Output.SetRedactor(redact.New(redact.ModeStrict)) + + require.NoError(t, RunAPI(context.Background(), opts)) + + require.NotContains(t, io.Output.String(), "notarealpassword-EXAMPLE") + require.Contains(t, io.Output.String(), "us-east-1") +} diff --git a/internal/commands/profile/property_docs.go b/internal/commands/profile/property_docs.go index 904e057..c62c3bc 100644 --- a/internal/commands/profile/property_docs.go +++ b/internal/commands/profile/property_docs.go @@ -42,6 +42,12 @@ func addCoreProperties(b *availablePropertiesBuilder) { Controls telemetry behavior. Set to {{ template "mdCodeOrBold" "false" }} or {{ template "mdCodeOrBold" "disabled" }} to disable telemetry, {{ template "mdCodeOrBold" "log" }} to output span data to stderr, or any other value to enable OTLP export.`) + b.AddProperty("", "redact", ` + Controls masking of sensitive values in output. {{ template "mdCodeOrBold" "strict" }} + (the default) masks known secret fields, values the API declares sensitive, and values + whose name or shape indicates a credential. {{ template "mdCodeOrBold" "known" }} masks + only known secret fields and declared sensitive values. + {{ template "mdCodeOrBold" "off" }} disables masking.`) } type availablePropertiesBuilder struct { diff --git a/internal/pkg/cmd/invocation.go b/internal/pkg/cmd/invocation.go index f820776..a85533d 100644 --- a/internal/pkg/cmd/invocation.go +++ b/internal/pkg/cmd/invocation.go @@ -21,6 +21,7 @@ import ( "github.com/hashicorp/tfctl-cli/internal/pkg/iostreams" "github.com/hashicorp/tfctl-cli/internal/pkg/logging" "github.com/hashicorp/tfctl-cli/internal/pkg/profile" + "github.com/hashicorp/tfctl-cli/internal/pkg/redact" "github.com/hashicorp/tfctl-cli/internal/pkg/telemetry" "github.com/hashicorp/tfctl-cli/version" ) @@ -57,6 +58,7 @@ type GlobalFlags struct { json bool markdown bool noColor bool + noRedact bool jq string debug int dryRun bool @@ -167,6 +169,12 @@ func ConfigureRootCommand(i *Invocation, cmd *Command) { Value: flagvalue.Simple(false, &i.flags.noColor), IsBooleanFlag: true, global: true, + }, &Flag{ + Name: "no-redact", + Description: "Shows sensitive values in output instead of masking them.", + Value: flagvalue.Simple(false, &i.flags.noRedact), + IsBooleanFlag: true, + global: true, }, &Flag{ Name: "debug", Description: "Enable debug output.", @@ -290,6 +298,12 @@ func (i *Invocation) applyGlobalFlags(_ *Command) error { i.Output.SetFormat(f) } + // Configure output redaction. API responses can carry credentials and signed + // URLs, so masking is on unless the user opts out. + if err := i.applyRedaction(); err != nil { + return err + } + // Disable color if set if i.flags.noColor || (i.Profile != nil && i.Profile.NoColor != nil && *i.Profile.NoColor) { i.IO.ForceNoColor() @@ -303,6 +317,38 @@ func (i *Invocation) applyGlobalFlags(_ *Command) error { return nil } +// applyRedaction resolves the redaction mode and configures the outputter. It +// warns when masking is off and stdout is not a terminal, because that is the +// case where a secret is most likely to be captured by a log, a file, or an +// automated caller rather than read once and discarded. +func (i *Invocation) applyRedaction() error { + if i.Output == nil { + return nil + } + + // An unusable setting must not stop the command. Masking falls back to + // strict, which is the safe direction, and the reason is reported. A profile + // that was edited by hand, or a typo in the environment, would otherwise + // make every command fail with no obvious way to recover. + mode, err := redact.ResolveMode(i.flags.noRedact, i.Profile.GetRedact()) + if err != nil { + mode = redact.ModeStrict + if i.IO != nil { + fmt.Fprintf(i.IO.Err(), "%s %v. Masking sensitive values in %s mode.\n", + i.IO.ColorScheme().WarningLabel(), err, mode) + } + } + + i.Output.SetRedactor(redact.New(mode)) + + if mode == redact.ModeOff && i.IO != nil && !i.IO.IsOutputTTY() { + fmt.Fprintf(i.IO.Err(), "%s output redaction is off. Sensitive values in API responses are written to stdout.\n", + i.IO.ColorScheme().WarningLabel()) + } + + return nil +} + // NewAPIClientForHost returns a new API Client configured using the specificed // hostname and token. func (i *Invocation) NewAPIClientForHost(hostname, token string) (*client.Client, error) { diff --git a/internal/pkg/cmd/redact_test.go b/internal/pkg/cmd/redact_test.go new file mode 100644 index 0000000..05686da --- /dev/null +++ b/internal/pkg/cmd/redact_test.go @@ -0,0 +1,120 @@ +// Copyright IBM Corp. 2026 +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "context" + "strings" + "testing" + + "github.com/hashicorp/tfctl-cli/internal/pkg/format" + "github.com/hashicorp/tfctl-cli/internal/pkg/iostreams" + "github.com/hashicorp/tfctl-cli/internal/pkg/profile" + "github.com/hashicorp/tfctl-cli/internal/pkg/redact" +) + +func newRedactionInvocation(t *testing.T, io iostreams.IOStreams) *Invocation { + t.Helper() + + inv := &Invocation{ + IO: io, + Output: format.New(io), + ShutdownCtx: context.Background(), + Profile: &profile.Profile{}, + } + inv.flags.parsed = true + return inv +} + +func TestApplyRedaction(t *testing.T) { + tests := []struct { + name string + noRedact bool + env string + profileRedact string + want redact.Mode + }{ + {name: "masking is on by default", want: redact.ModeStrict}, + {name: "--no-redact turns masking off", noRedact: true, want: redact.ModeOff}, + {name: "the environment selects a mode", env: "known", want: redact.ModeKnown}, + {name: "the profile selects a mode", profileRedact: "off", want: redact.ModeOff}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(redact.EnvRedact, tc.env) + + inv := newRedactionInvocation(t, iostreams.Test()) + inv.flags.noRedact = tc.noRedact + if tc.profileRedact != "" { + inv.Profile.Redact = &tc.profileRedact + } + + if err := inv.applyRedaction(); err != nil { + t.Fatalf("applyRedaction() error = %v", err) + } + + if got := inv.Output.Redactor().Mode(); got != tc.want { + t.Errorf("mode = %v, want %v", got, tc.want) + } + }) + } +} + +func TestApplyRedaction_InvalidValueFallsBackToStrict(t *testing.T) { + // A bad setting must not stop the command, and it must not quietly turn + // masking off. + t.Setenv(redact.EnvRedact, "banana") + + io := iostreams.Test() + inv := newRedactionInvocation(t, io) + + if err := inv.applyRedaction(); err != nil { + t.Fatalf("applyRedaction() error = %v, want the command to continue", err) + } + + if got := inv.Output.Redactor().Mode(); got != redact.ModeStrict { + t.Errorf("mode = %v, want %v", got, redact.ModeStrict) + } + + if !strings.Contains(io.Error.String(), `invalid redact value "banana"`) { + t.Errorf("stderr = %q, want the invalid value reported", io.Error.String()) + } +} + +func TestApplyRedaction_WarnsWhenMaskingIsOffAndOutputIsNotATerminal(t *testing.T) { + t.Setenv(redact.EnvRedact, "") + + io := iostreams.Test() + io.OutputTTY = false + + inv := newRedactionInvocation(t, io) + inv.flags.noRedact = true + + if err := inv.applyRedaction(); err != nil { + t.Fatalf("applyRedaction() error = %v", err) + } + + if !strings.Contains(io.Error.String(), "redaction is off") { + t.Errorf("stderr = %q, want a warning that redaction is off", io.Error.String()) + } +} + +func TestApplyRedaction_DoesNotWarnOnATerminal(t *testing.T) { + t.Setenv(redact.EnvRedact, "") + + io := iostreams.Test() + io.OutputTTY = true + + inv := newRedactionInvocation(t, io) + inv.flags.noRedact = true + + if err := inv.applyRedaction(); err != nil { + t.Fatalf("applyRedaction() error = %v", err) + } + + if io.Error.String() != "" { + t.Errorf("stderr = %q, want no warning when the user is watching the output", io.Error.String()) + } +} diff --git a/internal/pkg/format/jsonapi.go b/internal/pkg/format/jsonapi.go index 29a9ee6..0a8d199 100644 --- a/internal/pkg/format/jsonapi.go +++ b/internal/pkg/format/jsonapi.go @@ -16,6 +16,7 @@ import ( "golang.org/x/text/cases" "golang.org/x/text/language" + "github.com/hashicorp/tfctl-cli/internal/pkg/redact" "github.com/hashicorp/tfctl-cli/internal/pkg/resource" ) @@ -58,8 +59,11 @@ type JSONAPIDisplayer struct { logger hclog.Logger } -// Check interface at compile time. -var _ Displayer = JSONAPIDisplayer{} +// Check interfaces at compile time. +var ( + _ Displayer = JSONAPIDisplayer{} + _ Redactable = JSONAPIDisplayer{} +) // Any attribute keys that contain characters other than letters, numbers, hyphens, underscores, // and periods are skipped for display. Usually indicates user content in embedded @@ -86,6 +90,36 @@ func (d JSONAPIDisplayer) TemplatedPayload() any { return d.payload } +// Redacted implements the Redactable interface. Both views of the response are +// masked: the envelope that backs JSON output and the flattened rows that back +// table and pretty output. The redactor reports a masked field once even though +// it sees it in both views. +func (d JSONAPIDisplayer) Redacted(r *redact.Redactor) Displayer { + masked := d + + if envelope, ok := r.Apply(d.rawPayload).(map[string]any); ok { + masked.rawPayload = envelope + } else { + // Apply preserves the type of its input, so this cannot happen for a + // validated envelope. Fail closed rather than render the raw payload. + d.logger.Debug("Could not mask the JSON:API envelope, dropping the payload") + masked.rawPayload = map[string]any{} + } + + switch rows := d.payload.(type) { + case []map[string]any: + maskedRows := make([]map[string]any, len(rows)) + for i, row := range rows { + maskedRows[i] = r.ApplyRow(row) + } + masked.payload = maskedRows + case map[string]any: + masked.payload = r.ApplyRow(rows) + } + + return masked +} + // FieldTemplates implements the Displayer interface. func (d JSONAPIDisplayer) FieldTemplates() []Field { var cols []string diff --git a/internal/pkg/format/output.go b/internal/pkg/format/output.go index ff5f887..6b42cbf 100644 --- a/internal/pkg/format/output.go +++ b/internal/pkg/format/output.go @@ -7,6 +7,7 @@ import ( "bytes" "encoding/json" "fmt" + "io" "reflect" "slices" "strings" @@ -17,8 +18,13 @@ import ( "github.com/itchyny/gojq" "github.com/hashicorp/tfctl-cli/internal/pkg/iostreams" + "github.com/hashicorp/tfctl-cli/internal/pkg/redact" ) +// maxReportedRedactions is the number of masked field names listed before the +// report is summarized. +const maxReportedRedactions = 3 + // Displayer is the interface for displaying a given payload. By implementing // this interface, the payload can be outputted in any of the given Formats. type Displayer interface { @@ -226,6 +232,14 @@ type TemplatedPayload interface { TemplatedPayload() any } +// Redactable allows a Displayer whose payload comes from an API response to +// return a copy of itself with sensitive values masked. Display calls Redacted +// before it selects a format, so masking applies to every format, including +// JSON and the jq filter. +type Redactable interface { + Redacted(r *redact.Redactor) Displayer +} + // StringPayload allows a Displayer to provide pre-formatted string output for // pretty and markdown formats instead of using field templates. The displayer // receives the active Format so it can tailor the output (e.g. ANSI codes for @@ -291,6 +305,13 @@ type Outputter struct { // jqFilter is an optional jq filter expression to apply to JSON output. jqFilter string + + // redactor masks sensitive values in a payload before any format renders + // it. A nil redactor performs no masking. + redactor *redact.Redactor + + // reported is the number of masked fields already reported to the user. + reported int } // New returns an new outputter that will write to the provided IOStreams. @@ -315,6 +336,17 @@ func (o *Outputter) GetFormat() Format { return o.forcedFormat } +// SetRedactor sets the redactor used to mask sensitive values in every payload +// this outputter renders. +func (o *Outputter) SetRedactor(r *redact.Redactor) { + o.redactor = r +} + +// Redactor returns the configured redactor, which can be nil. +func (o *Outputter) Redactor() *redact.Redactor { + return o.redactor +} + // SetJQFilter sets a jq filter expression to apply to JSON output. func (o *Outputter) SetJQFilter(filter string) { o.jqFilter = filter @@ -323,6 +355,16 @@ func (o *Outputter) SetJQFilter(filter string) { // Display displays the passed Displayer. The format used is the DefaultFormat // unless the outputter has had a Format set which overrides the default. func (o *Outputter) Display(d Displayer) error { + // Mask sensitive values before a format is selected. Masking here rather + // than inside outputJSON keeps --json, --jq, and the templated formats + // consistent, and stops the jq filter from reaching a value that the JSON + // format would have hidden. + if o.redactor.Enabled() { + if r, ok := d.(Redactable); ok { + d = r.Redacted(o.redactor) + } + } + // Determine what format to use format := d.DefaultFormat() if o.forcedFormat != Unset { @@ -330,20 +372,115 @@ func (o *Outputter) Display(d Displayer) error { } // Display the payload based on the selected format. + var err error switch format { case Pretty: - return o.outputPretty(d) + err = o.outputPretty(d) case Table: - return o.outputTable(d) + err = o.outputTable(d) case Markdown: - return o.outputMarkdown(d) + err = o.outputMarkdown(d) case JSON: - return o.outputJSON(d) + err = o.outputJSON(d) case Agent: - return o.outputAgent(d) + err = o.outputAgent(d) + default: + return fmt.Errorf("invalid output format") } - return fmt.Errorf("invalid output format") + if err != nil { + return err + } + + o.ReportRedactions() + return nil +} + +// ReportRedactions tells the user which fields were masked, once per field. The +// report is unessential output, so --quiet suppresses it. +func (o *Outputter) ReportRedactions() { + if !o.redactor.Enabled() || o.redactor.Count() <= o.reported { + return + } + o.reported = o.redactor.Count() + + fields := o.redactor.Fields() + shown := fields + suffix := "" + if len(shown) > maxReportedRedactions { + shown = shown[:maxReportedRedactions] + suffix = fmt.Sprintf(" and %d more", len(fields)-maxReportedRedactions) + } + + cs := o.io.ColorScheme() + fmt.Fprintf(o.io.ErrUnessential(), "%s masked %d sensitive %s: %s%s. Use --no-redact to show %s.\n", + cs.WarningLabel(), + len(fields), + pluralize("field", len(fields)), + strings.Join(shown, ", "), + suffix, + pluralize("it", len(fields)), + ) +} + +func pluralize(word string, count int) string { + if count == 1 { + return word + } + if word == "it" { + return "them" + } + return word + "s" +} + +// CopyRaw writes a response body that no displayer handles, such as a plan JSON +// output document. Direct stdout is used because the body is an opaque stream +// that global format conversion does not apply to. +// +// When redaction is active and the body is JSON, the body is buffered and masked +// instead of streamed. Plan JSON contains every value Terraform wrote, including +// values that were marked sensitive in the configuration, so it cannot be +// exempt. The original bytes are written unchanged when nothing was masked, so +// output stays byte-for-byte identical in the common case. +func (o *Outputter) CopyRaw(body io.Reader, contentType string) error { + if !o.redactor.Enabled() || !isJSONContentType(contentType) { + _, err := io.Copy(o.io.Out(), body) + return err + } + + raw, err := io.ReadAll(body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + var decoded any + if err := json.Unmarshal(raw, &decoded); err != nil { + // The content type claimed JSON but the body is not JSON. Pass it + // through rather than dropping it. + _, err := o.io.Out().Write(raw) + return err + } + + before := o.redactor.Count() + masked := o.redactor.Apply(decoded) + if o.redactor.Count() == before { + _, err := o.io.Out().Write(raw) + return err + } + + data, err := json.MarshalIndent(masked, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal masked result to JSON: %w", err) + } + + fmt.Fprintln(o.io.Out(), string(data)) + o.ReportRedactions() + return nil +} + +func isJSONContentType(contentType string) bool { + mediaType := strings.TrimSpace(strings.Split(contentType, ";")[0]) + return mediaType == "application/json" || strings.HasSuffix(mediaType, "+json") } // Show outputs the given val using the DisplayFields function. diff --git a/internal/pkg/format/redact_corpus_test.go b/internal/pkg/format/redact_corpus_test.go new file mode 100644 index 0000000..bae81c2 --- /dev/null +++ b/internal/pkg/format/redact_corpus_test.go @@ -0,0 +1,322 @@ +// Copyright IBM Corp. 2026 +// SPDX-License-Identifier: MPL-2.0 + +package format_test + +import ( + "strings" + "testing" + + "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfctl-cli/internal/pkg/format" + "github.com/hashicorp/tfctl-cli/internal/pkg/iostreams" + "github.com/hashicorp/tfctl-cli/internal/pkg/redact" +) + +// The corpus below uses invented credentials only. Every value in mustNotAppear +// is either a published documentation example or a literal that says it is not +// real, so the corpus is safe to read, to share, and to paste into a bug report. +// Do not replace any of them with a value copied from a real response. +const ( + fakeAWSKeyID = "AKIAIOSFODNN7EXAMPLE" // AWS publishes this one in its own docs. + fakeTerraformToken = "EXAMPLEnotreal.atlasv1.notarealtokenvaluenotarealtokenvaluenotarealtoken00" + fakeVaultToken = "hvs.notarealtokenvalueEXAMPLE000000" + fakeGitHubToken = "ghp_notarealtokenvalueEXAMPLE0000000" + fakeJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJleGFtcGxlIn0.notarealsignatureEXAMPLE" + fakePassword = "notarealpassword-EXAMPLE" + fakeClientSecret = "notarealclientsecret-EXAMPLE" + fakeSignedURL = "https://archivist.terraform.io/v1/object/EXAMPLE?X-Amz-Signature=notarealsignature" + fakeUploadURL = "https://archivist.terraform.io/v1/upload/EXAMPLE?X-Amz-Signature=notarealsignature" + // The newlines are escaped rather than literal: this constant is embedded + // into a JSON document below, and JSON strings cannot hold a raw newline. + fakePrivateKey = `-----BEGIN RSA PRIVATE KEY-----\nTk9UQVJFQUxLRVlFWEFNUExF\n-----END RSA PRIVATE KEY-----` +) + +// redactCase is one synthetic response and what must and must not survive it. +type redactCase struct { + name string + + // envelope is a JSON:API response body. Exactly one of envelope or rawBody + // is set. + envelope string + + // rawBody is a response body that no displayer handles, such as plan JSON + // output. + rawBody string + + // mustNotAppear are the invented credentials. None of them may appear in any + // output format. + mustNotAppear []string + + // mustAppear are values a user needs to see. Masking them would make the + // command useless, so over-masking fails the test as loudly as leaking. + mustAppear []string + + // jqProbe is a filter that reaches straight for a credential. It must return + // the placeholder, not the value. + jqProbe string +} + +func redactCorpus() []redactCase { + return []redactCase{ + { + name: "state version download URLs grant access to all of state", + envelope: `{"data":{"id":"sv-EXAMPLE","type":"state-versions","attributes":{ + "serial": 42, + "status": "finalized", + "terraform-version": "1.9.8", + "hosted-state-download-url": "` + fakeSignedURL + `", + "hosted-json-state-download-url": "` + fakeSignedURL + `" + }}}`, + mustNotAppear: []string{fakeSignedURL, "notarealsignature"}, + mustAppear: []string{"finalized", "1.9.8", "42"}, + jqProbe: `.data.attributes["hosted-state-download-url"]`, + }, + { + name: "a created token is returned once in full", + envelope: `{"data":{"id":"at-EXAMPLE","type":"authentication-tokens","attributes":{ + "description": "ci runner", + "created-at": "2026-08-06T12:00:00Z", + "token": "` + fakeTerraformToken + `" + }}}`, + mustNotAppear: []string{fakeTerraformToken, "notarealtokenvalue"}, + mustAppear: []string{"ci runner"}, + jqProbe: `.data.attributes.token`, + }, + { + name: "configuration version upload URL", + envelope: `{"data":{"id":"cv-EXAMPLE","type":"configuration-versions","attributes":{ + "status": "pending", + "speculative": false, + "upload-url": "` + fakeUploadURL + `" + }}}`, + mustNotAppear: []string{fakeUploadURL}, + mustAppear: []string{"pending"}, + jqProbe: `.data.attributes["upload-url"]`, + }, + { + name: "variable the API declares sensitive but still returns", + envelope: `{"data":[ + {"id":"var-1","type":"vars","attributes":{"key":"region","value":"us-east-1","sensitive":false,"category":"terraform"}}, + {"id":"var-2","type":"vars","attributes":{"key":"tls_cert","value":"` + fakePassword + `","sensitive":true,"category":"terraform"}} + ]}`, + mustNotAppear: []string{fakePassword}, + mustAppear: []string{"us-east-1", "region", "tls_cert"}, + jqProbe: `.data[1].attributes.value`, + }, + { + name: "variable holding a secret that nobody marked sensitive", + envelope: `{"data":[ + {"id":"var-3","type":"vars","attributes":{"key":"db_password","value":"` + fakePassword + `","sensitive":false,"category":"terraform"}}, + {"id":"var-4","type":"vars","attributes":{"key":"aws_access_key","value":"` + fakeAWSKeyID + `","sensitive":false,"category":"env"}}, + {"id":"var-5","type":"vars","attributes":{"key":"vault_login","value":"` + fakeVaultToken + `","sensitive":false,"category":"env"}}, + {"id":"var-6","type":"vars","attributes":{"key":"gh_pat","value":"` + fakeGitHubToken + `","sensitive":false,"category":"env"}}, + {"id":"var-7","type":"vars","attributes":{"key":"id_token","value":"` + fakeJWT + `","sensitive":false,"category":"env"}}, + {"id":"var-8","type":"vars","attributes":{"key":"deploy_key","value":"` + fakePrivateKey + `","sensitive":false,"category":"env"}} + ]}`, + mustNotAppear: []string{ + fakePassword, fakeAWSKeyID, fakeVaultToken, + fakeGitHubToken, fakeJWT, "Tk9UQVJFQUxLRVlFWEFNUExF", + }, + mustAppear: []string{"db_password", "deploy_key"}, + jqProbe: `.data[1].attributes.value`, + }, + { + name: "oauth client secret", + envelope: `{"data":{"id":"oc-EXAMPLE","type":"oauth-clients","attributes":{ + "service-provider": "github", + "http-url": "https://github.com", + "secret": "` + fakeClientSecret + `", + "client-secret": "` + fakeClientSecret + `" + }}}`, + mustNotAppear: []string{fakeClientSecret}, + mustAppear: []string{"github"}, + jqProbe: `.data.attributes.secret`, + }, + { + name: "plan and apply log read URLs", + envelope: `{"data":{"id":"plan-EXAMPLE","type":"plans","attributes":{ + "status": "finished", + "has-changes": true, + "log-read-url": "` + fakeSignedURL + `" + }}}`, + mustNotAppear: []string{fakeSignedURL}, + mustAppear: []string{"finished"}, + jqProbe: `.data.attributes["log-read-url"]`, + }, + { + name: "a workspace must survive masking intact", + envelope: `{"data":{"id":"ws-EXAMPLE","type":"workspaces","attributes":{ + "name": "example-workspace", + "description": "nothing secret here", + "execution-mode": "agent", + "terraform-version": "1.9.8", + "vcs-repo": {"identifier":"example/repo","oauth-token-id":"ot-EXAMPLE","branch":"main"}, + "created-at": "2026-08-06T12:00:00Z" + }}}`, + mustNotAppear: nil, + // oauth-token-id names a credential, it is not one. Masking it would + // break every workflow that needs the VCS connection. + mustAppear: []string{"example-workspace", "ot-EXAMPLE", "example/repo", "agent", "main"}, + }, + { + name: "plan JSON output is not a JSON:API envelope", + rawBody: `{"format_version":"1.2","terraform_version":"1.9.8","variables":{ + "region":{"value":"us-east-1"}, + "db_password":{"value":"` + fakePassword + `"} + },"planned_values":{"outputs":{"api_token":{"sensitive":true,"value":"` + fakeTerraformToken + `"}}}}`, + mustNotAppear: []string{fakePassword, fakeTerraformToken}, + mustAppear: []string{"us-east-1", "1.9.8"}, + }, + } +} + +// renderedFormats returns every way a payload can reach stdout. A credential +// must not survive any of them. The default format is included because it is +// what a user sees when they pass no flags at all, which is how the state +// version URLs were being exposed. +func renderedFormats() map[string]format.Format { + return map[string]format.Format{ + "default": format.Unset, + "json": format.JSON, + "agent": format.Agent, + "markdown": format.Markdown, + "pretty": format.Pretty, + } +} + +// TestRedactCorpus asserts the invariant that matters: for every synthetic +// response, in every output format, no invented credential reaches stdout, and +// everything a user legitimately needs still does. +// +// Run it with -v to see each rendering, which is the quickest way to judge +// whether masking is too aggressive. +func TestRedactCorpus(t *testing.T) { + t.Parallel() + + for _, tc := range redactCorpus() { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if tc.rawBody != "" { + runRawCase(t, tc) + return + } + + for formatName, f := range renderedFormats() { + t.Run(formatName, func(t *testing.T) { + r := require.New(t) + + io := iostreams.Test() + out := format.New(io) + out.SetRedactor(redact.New(redact.ModeStrict)) + if f != format.Unset { + out.SetFormat(f) + } + + disp, err := format.NewJSONAPIDisplayer([]byte(tc.envelope), hclog.Default()) + r.NoError(err) + r.NoError(out.Display(disp)) + + stdout := io.Output.String() + t.Logf("%s output:\n%s", formatName, stdout) + + for _, secret := range tc.mustNotAppear { + r.NotContains(stdout, secret, "a credential reached %s output", formatName) + } + + // Table and pretty output truncate and wrap, so the + // over-masking check runs against the machine formats. + if f == format.JSON || f == format.Agent { + for _, needed := range tc.mustAppear { + r.Contains(stdout, needed, "masking removed a value the user needs") + } + } + }) + } + + if tc.jqProbe != "" { + t.Run("jq probe", func(t *testing.T) { + r := require.New(t) + + io := iostreams.Test() + out := format.New(io) + out.SetRedactor(redact.New(redact.ModeStrict)) + out.SetFormat(format.JSON) + out.SetJQFilter(tc.jqProbe) + + disp, err := format.NewJSONAPIDisplayer([]byte(tc.envelope), hclog.Default()) + r.NoError(err) + r.NoError(out.Display(disp)) + + stdout := strings.TrimSpace(io.Output.String()) + t.Logf("jq %s -> %s", tc.jqProbe, stdout) + + for _, secret := range tc.mustNotAppear { + r.NotContains(stdout, secret, "a jq filter reached around the mask") + } + r.Contains(stdout, redact.Placeholder) + }) + } + }) + } +} + +func runRawCase(t *testing.T, tc redactCase) { + t.Helper() + r := require.New(t) + + io := iostreams.Test() + out := format.New(io) + out.SetRedactor(redact.New(redact.ModeStrict)) + + r.NoError(out.CopyRaw(strings.NewReader(tc.rawBody), "application/json")) + + stdout := io.Output.String() + t.Logf("raw body output:\n%s", stdout) + + for _, secret := range tc.mustNotAppear { + r.NotContains(stdout, secret, "a credential reached a raw body") + } + for _, needed := range tc.mustAppear { + r.Contains(stdout, needed, "masking removed a value the user needs") + } +} + +// TestRedactCorpus_LeaksWithoutTheRedactor is the control. It proves the corpus +// actually carries the credentials it claims to, so a passing TestRedactCorpus +// cannot be the result of a corpus that was empty or misspelled. +func TestRedactCorpus_LeaksWithoutTheRedactor(t *testing.T) { + t.Parallel() + + for _, tc := range redactCorpus() { + if len(tc.mustNotAppear) == 0 { + continue + } + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + io := iostreams.Test() + out := format.New(io) + out.SetFormat(format.JSON) + + if tc.rawBody != "" { + r.NoError(out.CopyRaw(strings.NewReader(tc.rawBody), "application/json")) + } else { + disp, err := format.NewJSONAPIDisplayer([]byte(tc.envelope), hclog.Default()) + r.NoError(err) + r.NoError(out.Display(disp)) + } + + stdout := io.Output.String() + for _, secret := range tc.mustNotAppear { + r.Contains(stdout, secret, + "the corpus does not actually contain %q, so the masking test proves nothing", secret) + } + }) + } +} diff --git a/internal/pkg/format/redact_test.go b/internal/pkg/format/redact_test.go new file mode 100644 index 0000000..418bf5b --- /dev/null +++ b/internal/pkg/format/redact_test.go @@ -0,0 +1,256 @@ +// Copyright IBM Corp. 2026 +// SPDX-License-Identifier: MPL-2.0 + +package format_test + +import ( + "strings" + "testing" + + "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfctl-cli/internal/pkg/format" + "github.com/hashicorp/tfctl-cli/internal/pkg/iostreams" + "github.com/hashicorp/tfctl-cli/internal/pkg/redact" +) + +// stateVersionEnvelope is a state version response. The download URLs are signed +// capability URLs: anyone who holds one can read the whole state, which contains +// every value Terraform wrote. +const stateVersionEnvelope = `{ + "data": { + "id": "sv-abc123", + "type": "state-versions", + "attributes": { + "serial": 7, + "status": "finalized", + "hosted-state-download-url": "https://archivist.terraform.io/v1/object/secret-token-abc", + "hosted-json-state-download-url": "https://archivist.terraform.io/v1/object/secret-token-def" + } + } +}` + +func newRedactingOutputter(t *testing.T, mode redact.Mode) (*format.Outputter, *iostreams.Testing) { + t.Helper() + + io := iostreams.Test() + out := format.New(io) + out.SetRedactor(redact.New(mode)) + return out, io +} + +func TestDisplay_RedactsJSONOutput(t *testing.T) { + t.Parallel() + r := require.New(t) + + out, io := newRedactingOutputter(t, redact.ModeStrict) + out.SetFormat(format.JSON) + + disp, err := format.NewJSONAPIDisplayer([]byte(stateVersionEnvelope), hclog.Default()) + r.NoError(err) + r.NoError(out.Display(disp)) + + r.NotContains(io.Output.String(), "secret-token-abc") + r.NotContains(io.Output.String(), "secret-token-def") + r.Contains(io.Output.String(), redact.Placeholder) + // The escaping matters: an angle-bracket placeholder would be written as + // by encoding/json. + r.NotContains(io.Output.String(), `<`) + // Attributes that are not sensitive still render. + r.Contains(io.Output.String(), `"status": "finalized"`) +} + +func TestDisplay_RedactsBeforeTheJQFilter(t *testing.T) { + t.Parallel() + r := require.New(t) + + // A jq filter that selects the sensitive attribute directly must not be able + // to reach around the mask. + out, io := newRedactingOutputter(t, redact.ModeStrict) + out.SetFormat(format.JSON) + out.SetJQFilter(`.data.attributes["hosted-state-download-url"]`) + + disp, err := format.NewJSONAPIDisplayer([]byte(stateVersionEnvelope), hclog.Default()) + r.NoError(err) + r.NoError(out.Display(disp)) + + r.NotContains(io.Output.String(), "secret-token-abc") + r.Equal(redact.Placeholder, strings.TrimSpace(io.Output.String())) +} + +func TestDisplay_RedactsPrettyOutput(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Pretty is the default format for a single resource, and it prints every + // attribute that is not excluded, so it leaks the same values as --json. + out, io := newRedactingOutputter(t, redact.ModeStrict) + + disp, err := format.NewJSONAPIDisplayer([]byte(stateVersionEnvelope), hclog.Default()) + r.NoError(err) + r.NoError(out.Display(disp)) + + r.NotContains(io.Output.String(), "secret-token-abc") + r.NotContains(io.Output.String(), "secret-token-def") + r.Contains(io.Output.String(), redact.Placeholder) +} + +func TestDisplay_RedactsTableOutput(t *testing.T) { + t.Parallel() + r := require.New(t) + + const varsEnvelope = `{ + "data": [ + {"id":"var-1","type":"vars","attributes":{"key":"region","value":"us-east-1","sensitive":false,"category":"terraform"}}, + {"id":"var-2","type":"vars","attributes":{"key":"db_password","value":"hunter2","sensitive":false,"category":"terraform"}} + ] + }` + + out, io := newRedactingOutputter(t, redact.ModeStrict) + + disp, err := format.NewJSONAPIDisplayer([]byte(varsEnvelope), hclog.Default()) + r.NoError(err) + r.NoError(out.Display(disp)) + + r.NotContains(io.Output.String(), "hunter2") + r.Contains(io.Output.String(), "us-east-1") + r.Contains(io.Output.String(), redact.Placeholder) +} + +func TestDisplay_ReportsMaskedFieldsOnce(t *testing.T) { + t.Parallel() + r := require.New(t) + + out, io := newRedactingOutputter(t, redact.ModeStrict) + out.SetFormat(format.JSON) + + disp, err := format.NewJSONAPIDisplayer([]byte(stateVersionEnvelope), hclog.Default()) + r.NoError(err) + r.NoError(out.Display(disp)) + + report := io.Error.String() + r.Contains(report, "masked 2 sensitive fields") + r.Contains(report, "hosted-state-download-url") + r.Contains(report, "--no-redact") + + // Rendering the same payload again must not repeat the report. + io.Error.Reset() + r.NoError(out.Display(disp)) + r.Empty(io.Error.String()) +} + +func TestDisplay_NoRedactorLeavesThePayloadAlone(t *testing.T) { + t.Parallel() + r := require.New(t) + + io := iostreams.Test() + out := format.New(io) + out.SetFormat(format.JSON) + + disp, err := format.NewJSONAPIDisplayer([]byte(stateVersionEnvelope), hclog.Default()) + r.NoError(err) + r.NoError(out.Display(disp)) + + r.Contains(io.Output.String(), "secret-token-abc") + r.Empty(io.Error.String()) +} + +func TestDisplay_OffModeLeavesThePayloadAlone(t *testing.T) { + t.Parallel() + r := require.New(t) + + out, io := newRedactingOutputter(t, redact.ModeOff) + out.SetFormat(format.JSON) + + disp, err := format.NewJSONAPIDisplayer([]byte(stateVersionEnvelope), hclog.Default()) + r.NoError(err) + r.NoError(out.Display(disp)) + + r.Contains(io.Output.String(), "secret-token-abc") + r.Empty(io.Error.String()) +} + +func TestCopyRaw(t *testing.T) { + t.Parallel() + + // Plan JSON output is not a JSON:API envelope, so no displayer handles it. + // It holds the values Terraform wrote, whether or not the configuration + // marked them sensitive. + const planJSON = `{"format_version":"1.2","variables":{"db_password":{"value":"hunter2"}}}` + + t.Run("masks a JSON body", func(t *testing.T) { + t.Parallel() + r := require.New(t) + + out, io := newRedactingOutputter(t, redact.ModeStrict) + r.NoError(out.CopyRaw(strings.NewReader(planJSON), "application/json; charset=utf-8")) + + r.NotContains(io.Output.String(), "hunter2") + r.Contains(io.Output.String(), redact.Placeholder) + r.Contains(io.Error.String(), "masked 1 sensitive field") + }) + + t.Run("passes an unmasked JSON body through byte for byte", func(t *testing.T) { + t.Parallel() + r := require.New(t) + + const harmless = `{"format_version":"1.2","variables":{"region":{"value":"us-east-1"}}}` + + out, io := newRedactingOutputter(t, redact.ModeStrict) + r.NoError(out.CopyRaw(strings.NewReader(harmless), "application/json")) + + r.Equal(harmless, io.Output.String()) + r.Empty(io.Error.String()) + }) + + t.Run("passes a non-JSON body through", func(t *testing.T) { + t.Parallel() + r := require.New(t) + + const logOutput = "Terraform will perform the following actions" + + out, io := newRedactingOutputter(t, redact.ModeStrict) + r.NoError(out.CopyRaw(strings.NewReader(logOutput), "text/plain")) + + r.Equal(logOutput, io.Output.String()) + }) + + t.Run("passes a malformed JSON body through", func(t *testing.T) { + t.Parallel() + r := require.New(t) + + const malformed = `{"not":` + + out, io := newRedactingOutputter(t, redact.ModeStrict) + r.NoError(out.CopyRaw(strings.NewReader(malformed), "application/json")) + + r.Equal(malformed, io.Output.String()) + }) + + t.Run("streams when redaction is off", func(t *testing.T) { + t.Parallel() + r := require.New(t) + + out, io := newRedactingOutputter(t, redact.ModeOff) + r.NoError(out.CopyRaw(strings.NewReader(planJSON), "application/json")) + + r.Equal(planJSON, io.Output.String()) + }) +} + +func TestReportRedactions_QuietSuppressesTheReport(t *testing.T) { + t.Parallel() + r := require.New(t) + + out, io := newRedactingOutputter(t, redact.ModeStrict) + out.SetFormat(format.JSON) + io.SetQuiet(true) + + disp, err := format.NewJSONAPIDisplayer([]byte(stateVersionEnvelope), hclog.Default()) + r.NoError(err) + r.NoError(out.Display(disp)) + + r.NotContains(io.Output.String(), "secret-token-abc") + r.Empty(io.Error.String()) +} diff --git a/internal/pkg/profile/profile.go b/internal/pkg/profile/profile.go index 262d3bf..b461035 100644 --- a/internal/pkg/profile/profile.go +++ b/internal/pkg/profile/profile.go @@ -21,6 +21,8 @@ import ( "github.com/hashicorp/hcl/v2/hclwrite" "github.com/posener/complete" "golang.org/x/net/idna" + + "github.com/hashicorp/tfctl-cli/internal/pkg/redact" ) const ( @@ -96,6 +98,13 @@ type Profile struct { // "log" to write spans to stderr, or any other value (including empty) to enable OTLP export. Telemetry *string `hcl:"telemetry,optional" json:",omitempty"` + // Redact controls masking of sensitive values in output. Values: "strict" + // (the default) masks known secret fields, values the API declares + // sensitive, and values whose name or shape indicates a credential; "known" + // masks only known secret fields and declared sensitive values; "off" + // disables masking. + Redact *string `hcl:"redact,optional" json:",omitempty"` + // tokenFromEnv is the token extracted from the environment. This is not written to disk and is only used to allow GetToken // to return a token from the environment if one is not set on the profile. tokenFromEnv string @@ -112,6 +121,7 @@ func (p *Profile) Predict(args complete.Args) []string { properties := map[string][]string{ "no_color": {"true", "false"}, "telemetry": {"true", "false", "disabled", "log"}, + "redact": {"strict", "known", "off"}, } // If the property has been specified, return possible values. @@ -124,7 +134,7 @@ func (p *Profile) Predict(args complete.Args) []string { // predicting the property if len(args.All) == 1 { - return []string{"default_organization", "no_color", "hostname", "token", "telemetry"} + return []string{"default_organization", "no_color", "hostname", "token", "telemetry", "redact"} } return nil @@ -151,6 +161,10 @@ func (p *Profile) Validate() error { err = multierror.Append(err, ErrInvalidProfileName) } + if _, modeErr := redact.ParseMode(p.GetRedact()); modeErr != nil { + err = multierror.Append(err, modeErr) + } + err.ErrorFormat = func(errors []error) string { if len(errors) == 1 { return errors[0].Error() @@ -364,3 +378,12 @@ func (p *Profile) GetTelemetry() string { return *p.Telemetry } + +// GetRedact returns the redaction setting or an empty string if unset. +func (p *Profile) GetRedact() string { + if p == nil || p.Redact == nil { + return "" + } + + return *p.Redact +} diff --git a/internal/pkg/profile/profile_test.go b/internal/pkg/profile/profile_test.go index fd40b63..a5521aa 100644 --- a/internal/pkg/profile/profile_test.go +++ b/internal/pkg/profile/profile_test.go @@ -87,7 +87,14 @@ func TestProfile_Predict(t *testing.T) { Args: complete.Args{ All: []string{""}, }, - Expected: []string{"default_organization", "no_color", "hostname", "token", "telemetry"}, + Expected: []string{"default_organization", "no_color", "hostname", "token", "telemetry", "redact"}, + }, + { + Name: "redact values", + Args: complete.Args{ + All: []string{"redact"}, + }, + Expected: []string{"strict", "known", "off"}, }, } diff --git a/internal/pkg/redact/redact.go b/internal/pkg/redact/redact.go new file mode 100644 index 0000000..c9a4cbc --- /dev/null +++ b/internal/pkg/redact/redact.go @@ -0,0 +1,556 @@ +// Copyright IBM Corp. 2026 +// SPDX-License-Identifier: MPL-2.0 + +// Package redact masks sensitive values in decoded API payloads before any +// output format renders them. +// +// This is an output filter, not an access-control boundary. The API decides +// what a token is permitted to read. Redaction limits what a permitted response +// leaves behind in a terminal, a shell history, a CI job log, or a coding-agent +// transcript. A response attribute that is a credential, or a signed URL that +// grants access to state, must not reach stdout by accident. +package redact + +import ( + "fmt" + "os" + "regexp" + "sort" + "strings" +) + +// Placeholder is the value written in place of a masked value. It follows the +// style that Profile.String uses for the stored token. Angle brackets are +// avoided on purpose: encoding/json escapes them, so an angle-bracket +// placeholder reaches JSON output as "". +const Placeholder = "(redacted)" + +// EnvRedact is the environment variable that controls the redaction mode. +const EnvRedact = "TFCTL_REDACT" + +// Mode selects how aggressively values are masked. +type Mode int + +const ( + // ModeStrict masks known secret fields, values the API declares sensitive, + // and values whose name or shape indicates a credential. This is the + // default. + ModeStrict Mode = iota + + // ModeKnown masks only known secret fields and values the API declares + // sensitive. Use it when a name or shape heuristic hides a value that is + // needed. + ModeKnown + + // ModeOff performs no masking. + ModeOff +) + +// String returns the canonical name of the mode. +func (m Mode) String() string { + switch m { + case ModeKnown: + return "known" + case ModeOff: + return "off" + default: + return "strict" + } +} + +// ParseMode converts a configured value into a Mode. +func ParseMode(value string) (Mode, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "strict", "on", "true", "1": + return ModeStrict, nil + case "known": + return ModeKnown, nil + case "off", "false", "0", "disabled", "none": + return ModeOff, nil + } + + return ModeStrict, fmt.Errorf("invalid redact value %q. Must be one of \"strict\", \"known\", or \"off\"", value) +} + +// ResolveMode determines the redaction mode. The --no-redact flag takes +// precedence over the environment variable, which takes precedence over the +// profile setting. +// +// Resolution order: +// 1. noRedact flag → ModeOff +// 2. TFCTL_REDACT +// 3. Profile redact +// 4. Otherwise → ModeStrict +func ResolveMode(noRedact bool, profileRedact string) (Mode, error) { + if noRedact { + return ModeOff, nil + } + + if envValue := os.Getenv(EnvRedact); envValue != "" { + mode, err := ParseMode(envValue) + if err != nil { + return ModeStrict, fmt.Errorf("%s: %w", EnvRedact, err) + } + return mode, nil + } + + if profileRedact != "" { + mode, err := ParseMode(profileRedact) + if err != nil { + return ModeStrict, fmt.Errorf("profile redact: %w", err) + } + return mode, nil + } + + return ModeStrict, nil +} + +// knownSecretFields are attribute names that hold a credential in the HCP +// Terraform and Terraform Enterprise API. The match is on the final segment of +// the attribute path and is case-insensitive. +var knownSecretFields = map[string]struct{}{ + // Returned once when a user, team, organization, or agent token is created. + "token": {}, + // OAuth client secret and SSH key material. + "secret": {}, + "private-ssh-key": {}, + "encryption-password": {}, + // Request headers, which a dry run reports back to the user. + "authorization": {}, + "proxy-authorization": {}, +} + +// capabilityURLFields are attribute-name fragments that indicate a signed URL. +// The URL is itself the credential: anyone holding it can read the object +// without a token. State downloads and plan or apply logs contain every value +// that Terraform wrote, whether or not the variable was marked sensitive. +var capabilityURLFields = []string{ + "download-url", + "upload-url", + "log-read-url", +} + +// declaredSensitiveFields are the attribute names masked when the containing +// object declares itself sensitive with "sensitive": true. Variables and state +// version outputs use this shape. +var declaredSensitiveFields = map[string]struct{}{ + "value": {}, +} + +// sensitiveNameNeedles indicate a credential in a name. API attribute names +// are kebab-case and Terraform variable and output names are snake_case, so +// both forms are listed. +var sensitiveNameNeedles = []string{ + "secret", + "token", + "password", + "passwd", + "passphrase", + "credential", + "private-key", + "private_key", + "privatekey", + "ssh-key", + "ssh_key", + "access-key", + "access_key", + "secret-key", + "secret_key", + "api-key", + "api_key", + "apikey", + "signing-key", + "signing_key", +} + +// structuralNameSuffixes mark an attribute that describes a credential rather +// than holding one, such as "oauth-token-id" or "ssh-key-name". The name +// heuristic skips them. +var structuralNameSuffixes = []string{ + "-id", + "_id", + "-ids", + "_ids", + "-name", + "_name", + "-count", + "-at", +} + +// valueDetectors match values whose shape identifies a credential, which +// catches a secret held in an attribute that nobody marked sensitive. +var valueDetectors = []struct { + reason string + match func(string) bool +}{ + { + // Signed URLs for archivist, S3, or GCS. Presigned query parameters are + // the credential. + reason: "signed-url", + match: func(s string) bool { + if !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") { + return false + } + for _, marker := range []string{"X-Amz-Signature=", "X-Goog-Signature=", "&Signature=", "?Signature=", "archivist.terraform.io"} { + if strings.Contains(s, marker) { + return true + } + } + return false + }, + }, + {reason: "private-key", match: rePrivateKey.MatchString}, + {reason: "jwt", match: reJWT.MatchString}, + {reason: "terraform-token", match: reTerraformToken.MatchString}, + {reason: "vault-token", match: reVaultToken.MatchString}, + {reason: "github-token", match: reGitHubToken.MatchString}, + {reason: "aws-access-key-id", match: reAWSAccessKeyID.MatchString}, +} + +var ( + rePrivateKey = regexp.MustCompile(`-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----`) + reJWT = regexp.MustCompile(`^eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}$`) + reTerraformToken = regexp.MustCompile(`^[A-Za-z0-9]{14}\.(atlasv1|hcp)\.[A-Za-z0-9_-]{40,}$`) + reVaultToken = regexp.MustCompile(`^hv[sbr]\.[A-Za-z0-9_-]{20,}$`) + reGitHubToken = regexp.MustCompile(`^gh[pousr]_[A-Za-z0-9]{20,}$`) + reAWSAccessKeyID = regexp.MustCompile(`^(AKIA|ASIA)[0-9A-Z]{16}$`) +) + +// Redactor masks sensitive values in a decoded JSON tree. A Redactor records +// which fields it masked so that a command can tell the user what was hidden. +// It is not safe for concurrent use. +type Redactor struct { + mode Mode + + // masked records the reason for each masked field name. Field names are + // used instead of full paths so that masking the same field in a + // collection, or in two views of one response, reports once. + masked map[string]string +} + +// New returns a Redactor for the given mode. +func New(mode Mode) *Redactor { + return &Redactor{mode: mode, masked: map[string]string{}} +} + +// Enabled reports whether the Redactor masks anything. A nil Redactor is +// disabled. +func (r *Redactor) Enabled() bool { + return r != nil && r.mode != ModeOff +} + +// Mode returns the configured mode. +func (r *Redactor) Mode() Mode { + if r == nil { + return ModeOff + } + return r.mode +} + +// Count returns the number of distinct fields that were masked. +func (r *Redactor) Count() int { + if r == nil { + return 0 + } + return len(r.masked) +} + +// Fields returns the sorted names of the masked fields. +func (r *Redactor) Fields() []string { + if r == nil { + return nil + } + + fields := make([]string, 0, len(r.masked)) + for field := range r.masked { + fields = append(fields, field) + } + sort.Strings(fields) + return fields +} + +// Reasons returns the masked field names mapped to the rule that masked them. +// It is intended for debug logging. +func (r *Redactor) Reasons() map[string]string { + if r == nil { + return nil + } + + reasons := make(map[string]string, len(r.masked)) + for field, reason := range r.masked { + reasons[field] = reason + } + return reasons +} + +// Apply returns the decoded JSON value with sensitive values masked. The input +// is never modified, so applying a Redactor to two views of one response is +// safe. +// +// Copying is on write. A value with nothing to mask is returned as it was +// received, without allocating, because most responses hold no credential at +// all and paying for a full copy of every response to mask nothing is not +// acceptable on a large body. Only the containers on the path to a masked value +// are rebuilt. +func (r *Redactor) Apply(value any) any { + if !r.Enabled() { + return value + } + + masked, _ := r.walk("", value) + return masked +} + +// MaskHeader masks a header value when the header name or the value itself +// indicates a credential. It returns the value unchanged and false when the +// header carries nothing sensitive. +// +// Authorization is the obvious case, but a user can pass any header with +// --header, including a vendor API key header, so the same name and shape rules +// apply as for a response attribute. +func (r *Redactor) MaskHeader(name, value string) (string, bool) { + if !r.Enabled() || value == "" { + return value, false + } + + if reason, ok := r.matchKey(name, value, false, ""); ok { + return r.mask(strings.ToLower(name), reason), true + } + + if reason, ok := r.matchValue(value); ok { + return r.mask(strings.ToLower(name), reason), true + } + + return value, false +} + +// ApplyRow masks a flattened display row. Row keys can be dot-separated paths, +// and the final segment is used for name matching. +func (r *Redactor) ApplyRow(row map[string]any) map[string]any { + if !r.Enabled() { + return row + } + + masked, ok := r.Apply(row).(map[string]any) + if !ok { + return row + } + return masked +} + +// walk masks what must not be shown and reports whether it changed anything. +// +// The returned value is the input itself when nothing was masked, so a subtree +// with no credential in it costs no allocation. When something is masked, only +// the containers between the root and that value are rebuilt; every untouched +// subtree is shared with the input. The input is never written to. +// +// name is the key under which this value sits. It serves two purposes: it names +// the field in the report, and it tells an object that does not label its own +// value what that value is called. A variable object carries its own name in a +// "key" attribute, but Terraform plan JSON nests the name as the map key, as in +// variables.db_password.value, and both forms have to reach the "value" +// attribute below them. +// +// Every unchanged path returns value rather than typed. That is not a stylistic +// choice: value is already an interface, while returning typed re-boxes it, and +// boxing a string or a slice header allocates. Returning typed costs one +// allocation per leaf, which is the whole cost this function exists to avoid. +// TestApplyReturnsTheInputWhenNothingIsMasked pins it at zero. +func (r *Redactor) walk(name string, value any) (any, bool) { + switch typed := value.(type) { + case map[string]any: + declared := declaresSensitive(typed) + + hint := nameHint(typed) + if hint == "" { + hint = name + } + + // out stays nil until the first change, at which point the whole map is + // shallow-copied. Everything iterated before that point was unchanged, + // so the copy is correct, and the changed entry is written immediately + // after. + var out map[string]any + + for key, child := range typed { + var ( + masked any + changed bool + ) + + if reason, ok := r.matchKey(key, child, declared, hint); ok { + masked, changed = r.mask(key, reason), true + } else { + masked, changed = r.walk(key, child) + } + + if !changed { + continue + } + + if out == nil { + out = make(map[string]any, len(typed)) + for k, v := range typed { + out[k] = v + } + } + out[key] = masked + } + + if out == nil { + return value, false + } + return out, true + case []any: + // Elements inherit the name of the attribute holding the list, which is + // what a reader needs to see reported. The index is not a field name. + var out []any + + for i, item := range typed { + masked, changed := r.walk(name, item) + if !changed { + continue + } + + if out == nil { + out = make([]any, len(typed)) + copy(out, typed) + } + out[i] = masked + } + + if out == nil { + return value, false + } + return out, true + case string: + if reason, ok := r.matchValue(typed); ok { + return r.mask(name, reason), true + } + return value, false + default: + return value, false + } +} + +// matchKey reports whether an attribute must be masked because of its name. +// nameHint carries the name that a self-describing object gives itself, which +// is the variable key or output name for a "value" attribute. +func (r *Redactor) matchKey(key string, value any, declaredSensitive bool, nameHint string) (string, bool) { + // A value the server did not send cannot leak. Leave null and empty values + // alone so that output still shows the server actually withheld them. + if value == nil { + return "", false + } + if str, ok := value.(string); ok && str == "" { + return "", false + } + + name := strings.ToLower(lastSegment(key)) + _, holdsDeclaredValue := declaredSensitiveFields[name] + + if declaredSensitive && holdsDeclaredValue { + return "declared-sensitive", true + } + + if _, ok := knownSecretFields[name]; ok { + return "known-secret-field", true + } + + for _, fragment := range capabilityURLFields { + if strings.Contains(name, fragment) { + return "capability-url", true + } + } + + if r.mode != ModeStrict { + return "", false + } + + // Only string values are masked by the name heuristic. A boolean or number + // is not a credential, and masking "sensitive": true would hide the very + // marker that drives the declared-sensitive rule. + if _, ok := value.(string); !ok { + return "", false + } + + if looksSensitiveName(name) { + return "sensitive-field-name", true + } + + // A variable or output holds its own name next to its value, or is nested + // under it. The attribute is always called "value", so the name that + // indicates a credential is the one the enclosing structure gives it. This + // catches a variable that holds a secret but that nobody marked sensitive. + if holdsDeclaredValue && nameHint != "" && looksSensitiveName(strings.ToLower(nameHint)) { + return "sensitive-object-name", true + } + + return "", false +} + +// matchValue reports whether a value must be masked because of its shape. +func (r *Redactor) matchValue(value string) (string, bool) { + if r.mode != ModeStrict || value == "" { + return "", false + } + + for _, detector := range valueDetectors { + if detector.match(value) { + return detector.reason, true + } + } + + return "", false +} + +func (r *Redactor) mask(field, reason string) string { + if field == "" { + field = "(value)" + } + if _, seen := r.masked[field]; !seen { + r.masked[field] = reason + } + return Placeholder +} + +func looksSensitiveName(name string) bool { + for _, suffix := range structuralNameSuffixes { + if strings.HasSuffix(name, suffix) { + return false + } + } + + for _, needle := range sensitiveNameNeedles { + if strings.Contains(name, needle) { + return true + } + } + + return false +} + +// declaresSensitive reports whether an object marks its own value sensitive. +func declaresSensitive(object map[string]any) bool { + sensitive, ok := object["sensitive"].(bool) + return ok && sensitive +} + +// nameHint returns the name that an object reports for itself. Variables use +// "key" and state version outputs use "name". +func nameHint(object map[string]any) string { + for _, field := range []string{"key", "name"} { + if value, ok := object[field].(string); ok && value != "" { + return value + } + } + return "" +} + +func lastSegment(path string) string { + if index := strings.LastIndex(path, "."); index >= 0 { + return path[index+1:] + } + return path +} diff --git a/internal/pkg/redact/redact_test.go b/internal/pkg/redact/redact_test.go new file mode 100644 index 0000000..916cc15 --- /dev/null +++ b/internal/pkg/redact/redact_test.go @@ -0,0 +1,401 @@ +// Copyright IBM Corp. 2026 +// SPDX-License-Identifier: MPL-2.0 + +package redact + +import ( + "encoding/json" + "reflect" + "testing" +) + +// applyJSON runs a Redactor over a JSON document and returns the result as +// JSON, which keeps the test cases readable. +func applyJSON(t *testing.T, mode Mode, document string) (string, *Redactor) { + t.Helper() + + var decoded any + if err := json.Unmarshal([]byte(document), &decoded); err != nil { + t.Fatalf("test document is not valid JSON: %v", err) + } + + r := New(mode) + masked, err := json.Marshal(r.Apply(decoded)) + if err != nil { + t.Fatalf("could not marshal masked document: %v", err) + } + + return string(masked), r +} + +func TestApply(t *testing.T) { + tests := []struct { + name string + mode Mode + document string + want string + }{ + { + name: "state version download URLs are capability URLs", + mode: ModeStrict, + document: `{"data":{"type":"state-versions","attributes":{"serial":7,"hosted-state-download-url":"https://archivist.terraform.io/v1/object/abc","hosted-json-state-download-url":"https://archivist.terraform.io/v1/object/def"}}}`, + want: `{"data":{"attributes":{"hosted-json-state-download-url":"(redacted)","hosted-state-download-url":"(redacted)","serial":7},"type":"state-versions"}}`, + }, + { + name: "capability URLs are masked in the known mode", + mode: ModeKnown, + document: `{"data":{"attributes":{"hosted-state-download-url":"https://archivist.terraform.io/v1/object/abc"}}}`, + want: `{"data":{"attributes":{"hosted-state-download-url":"(redacted)"}}}`, + }, + { + name: "configuration version upload URL", + mode: ModeKnown, + document: `{"data":{"attributes":{"upload-url":"https://archivist.terraform.io/v1/object/ghi","status":"pending"}}}`, + want: `{"data":{"attributes":{"status":"pending","upload-url":"(redacted)"}}}`, + }, + { + name: "created token is a known secret field", + mode: ModeKnown, + document: `{"data":{"type":"authentication-tokens","attributes":{"description":"ci","token":"abcdefghijklmn.atlasv1.zzz"}}}`, + want: `{"data":{"attributes":{"description":"ci","token":"(redacted)"},"type":"authentication-tokens"}}`, + }, + { + name: "declared sensitive value", + mode: ModeKnown, + document: `{"data":{"attributes":{"key":"harmless","value":"visible-secret","sensitive":true}}}`, + want: `{"data":{"attributes":{"key":"harmless","sensitive":true,"value":"(redacted)"}}}`, + }, + { + name: "value the server already withheld stays null", + mode: ModeStrict, + document: `{"data":{"attributes":{"key":"db_password","value":null,"sensitive":true}}}`, + want: `{"data":{"attributes":{"key":"db_password","sensitive":true,"value":null}}}`, + }, + { + name: "variable that nobody marked sensitive is masked by its own name", + mode: ModeStrict, + document: `{"data":{"attributes":{"key":"db_password","value":"hunter2","sensitive":false,"category":"terraform"}}}`, + want: `{"data":{"attributes":{"category":"terraform","key":"db_password","sensitive":false,"value":"(redacted)"}}}`, + }, + { + name: "the known mode does not apply the name heuristic", + mode: ModeKnown, + document: `{"data":{"attributes":{"key":"db_password","value":"hunter2","sensitive":false}}}`, + want: `{"data":{"attributes":{"key":"db_password","sensitive":false,"value":"hunter2"}}}`, + }, + { + name: "attribute name that indicates a credential", + mode: ModeStrict, + document: `{"data":{"attributes":{"client-secret":"shhhh","service-provider":"github"}}}`, + want: `{"data":{"attributes":{"client-secret":"(redacted)","service-provider":"github"}}}`, + }, + { + name: "identifier that only describes a credential is kept", + mode: ModeStrict, + document: `{"data":{"attributes":{"vcs-repo":{"oauth-token-id":"ot-abc123","identifier":"my-org/my-repo"}}}}`, + want: `{"data":{"attributes":{"vcs-repo":{"identifier":"my-org/my-repo","oauth-token-id":"ot-abc123"}}}}`, + }, + { + name: "sensitive marker itself is never masked", + mode: ModeStrict, + document: `{"data":{"attributes":{"sensitive":true,"name":"vpc_id","value":"vpc-123"}}}`, + want: `{"data":{"attributes":{"name":"vpc_id","sensitive":true,"value":"(redacted)"}}}`, + }, + { + name: "private key shape in an unremarkable attribute", + mode: ModeStrict, + document: `{"data":{"attributes":{"description":"-----BEGIN RSA PRIVATE KEY-----\nMIIE\n-----END RSA PRIVATE KEY-----"}}}`, + want: `{"data":{"attributes":{"description":"(redacted)"}}}`, + }, + { + name: "presigned URL shape in an unremarkable attribute", + mode: ModeStrict, + document: `{"data":{"attributes":{"notification-url":"https://example.s3.amazonaws.com/x?X-Amz-Signature=deadbeef"}}}`, + want: `{"data":{"attributes":{"notification-url":"(redacted)"}}}`, + }, + { + name: "terraform token shape", + mode: ModeStrict, + document: `{"data":{"attributes":{"note":"abcdefghijklmn.atlasv1.0123456789012345678901234567890123456789012345"}}}`, + want: `{"data":{"attributes":{"note":"(redacted)"}}}`, + }, + { + name: "ordinary URL is kept", + mode: ModeStrict, + document: `{"data":{"attributes":{"vcs-commit-url":"https://github.com/my-org/my-repo/commit/abc"}}}`, + want: `{"data":{"attributes":{"vcs-commit-url":"https://github.com/my-org/my-repo/commit/abc"}}}`, + }, + { + name: "values inside a collection", + mode: ModeStrict, + document: `{"data":[{"attributes":{"key":"a","value":"1","sensitive":false}},{"attributes":{"key":"api_key","value":"2","sensitive":false}}]}`, + want: `{"data":[{"attributes":{"key":"a","sensitive":false,"value":"1"}},{"attributes":{"key":"api_key","sensitive":false,"value":"(redacted)"}}]}`, + }, + { + name: "the off mode changes nothing", + mode: ModeOff, + document: `{"data":{"attributes":{"token":"abcdefghijklmn.atlasv1.zzz"}}}`, + want: `{"data":{"attributes":{"token":"abcdefghijklmn.atlasv1.zzz"}}}`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, _ := applyJSON(t, tc.mode, tc.document) + if got != tc.want { + t.Errorf("Apply() mismatch\n got: %s\nwant: %s", got, tc.want) + } + }) + } +} + +func TestApplyDoesNotModifyTheInput(t *testing.T) { + document := `{"data":{"attributes":{"hosted-state-download-url":"https://archivist.terraform.io/v1/object/abc"}}}` + + var decoded any + if err := json.Unmarshal([]byte(document), &decoded); err != nil { + t.Fatalf("test document is not valid JSON: %v", err) + } + + r := New(ModeStrict) + _ = r.Apply(decoded) + + after, err := json.Marshal(decoded) + if err != nil { + t.Fatalf("could not marshal the original document: %v", err) + } + + if string(after) != document { + t.Errorf("Apply() modified its input\n got: %s\nwant: %s", after, document) + } +} + +// mapPointer identifies the backing store of a map or slice so a test can tell +// a shared reference from a copy. +func mapPointer(t *testing.T, value any) uintptr { + t.Helper() + + rv := reflect.ValueOf(value) + if rv.Kind() != reflect.Map && rv.Kind() != reflect.Slice { + t.Fatalf("value is a %s, not a map or slice", rv.Kind()) + } + return rv.Pointer() +} + +func mustDecode(t *testing.T, document string) any { + t.Helper() + + var decoded any + if err := json.Unmarshal([]byte(document), &decoded); err != nil { + t.Fatalf("test document is not valid JSON: %v", err) + } + return decoded +} + +func TestApplyReturnsTheInputWhenNothingIsMasked(t *testing.T) { + // Copying every response in order to mask nothing is the common case, and on + // a large plan JSON document it is also the expensive case. Nothing to mask + // has to mean nothing to copy. + decoded := mustDecode(t, `{"data":{"id":"ws-1","type":"workspaces","attributes":{ + "name":"example","execution-mode":"agent", + "vcs-repo":{"identifier":"my-org/my-repo","oauth-token-id":"ot-abc"} + }}}`) + + r := New(ModeStrict) + masked := r.Apply(decoded) + + if r.Count() != 0 { + t.Fatalf("Count() = %d, want 0; this document is supposed to be clean", r.Count()) + } + if mapPointer(t, masked) != mapPointer(t, decoded) { + t.Error("Apply() copied a document that had nothing to mask") + } + + // Measure the walk alone. Constructing a Redactor allocates a small fixed + // amount, which would hide the number that matters. Repeating the walk over a + // clean document records nothing, so reusing one Redactor is safe here. + allocs := testing.AllocsPerRun(5, func() { + _ = r.Apply(decoded) + }) + if allocs != 0 { + t.Errorf("Apply() over a clean document made %.0f allocations, want 0", allocs) + } +} + +func TestApplySharesUntouchedSubtrees(t *testing.T) { + // When something is masked, only the containers between the root and the + // masked value are rebuilt. A sibling subtree must be shared, not copied. + decoded := mustDecode(t, `{"data":{"attributes":{ + "token":"abcdefghijklmn.atlasv1.zzz", + "vcs-repo":{"identifier":"my-org/my-repo","oauth-token-id":"ot-abc"} + }}}`) + + r := New(ModeStrict) + masked := r.Apply(decoded) + + if r.Count() != 1 { + t.Fatalf("Count() = %d, want 1", r.Count()) + } + + attributesOf := func(root any) map[string]any { + return root.(map[string]any)["data"].(map[string]any)["attributes"].(map[string]any) + } + + original := attributesOf(decoded) + rebuilt := attributesOf(masked) + + if mapPointer(t, rebuilt) == mapPointer(t, original) { + t.Error("the container holding the masked value was not rebuilt, so the input was written to") + } + if mapPointer(t, rebuilt["vcs-repo"]) != mapPointer(t, original["vcs-repo"]) { + t.Error("an untouched sibling subtree was copied instead of shared") + } + if original["token"] == Placeholder { + t.Error("Apply() masked its input in place") + } +} + +func TestApplyReportsTheAttributeNameForAListElement(t *testing.T) { + // A masked element inside a list is reported under the attribute that holds + // the list. An index is not a field name and tells the reader nothing. + decoded := mustDecode(t, `{"data":{"attributes":{"deploy-keys":["harmless","hvs.notarealtokenvalueEXAMPLE00000"]}}}`) + + r := New(ModeStrict) + _ = r.Apply(decoded) + + want := []string{"deploy-keys"} + if !reflect.DeepEqual(r.Fields(), want) { + t.Errorf("Fields() = %v, want %v", r.Fields(), want) + } +} + +func TestApplyIsRepeatableAcrossViews(t *testing.T) { + // A JSON:API response is rendered from two views of one payload: the + // envelope for JSON output and flattened rows for table output. Masking + // both must report one field, not two. + envelope := `{"data":{"attributes":{"hosted-state-download-url":"https://archivist.terraform.io/v1/object/abc"}}}` + + var decoded any + if err := json.Unmarshal([]byte(envelope), &decoded); err != nil { + t.Fatalf("test document is not valid JSON: %v", err) + } + + r := New(ModeStrict) + _ = r.Apply(decoded) + _ = r.ApplyRow(map[string]any{"hosted-state-download-url": "https://archivist.terraform.io/v1/object/abc"}) + + if r.Count() != 1 { + t.Errorf("Count() = %d, want 1 (fields must report once across views)", r.Count()) + } + + want := []string{"hosted-state-download-url"} + if !reflect.DeepEqual(r.Fields(), want) { + t.Errorf("Fields() = %v, want %v", r.Fields(), want) + } +} + +func TestApplyRowUsesTheFinalPathSegment(t *testing.T) { + // Flattened display rows use dot-separated keys. + r := New(ModeStrict) + row := r.ApplyRow(map[string]any{ + "vcs-repo.oauth-token-id": "ot-abc123", + "vcs-repo.webhook-secret": "shhhh", + }) + + if row["vcs-repo.oauth-token-id"] != "ot-abc123" { + t.Errorf("oauth-token-id was masked, want it kept: %v", row["vcs-repo.oauth-token-id"]) + } + if row["vcs-repo.webhook-secret"] != Placeholder { + t.Errorf("webhook-secret = %v, want %s", row["vcs-repo.webhook-secret"], Placeholder) + } +} + +func TestReasons(t *testing.T) { + _, r := applyJSON(t, ModeStrict, `{"data":{"attributes":{"key":"db_password","value":"hunter2","sensitive":false,"hosted-state-download-url":"https://archivist.terraform.io/v1/object/abc"}}}`) + + want := map[string]string{ + "value": "sensitive-object-name", + "hosted-state-download-url": "capability-url", + } + + if !reflect.DeepEqual(r.Reasons(), want) { + t.Errorf("Reasons() = %v, want %v", r.Reasons(), want) + } +} + +func TestParseMode(t *testing.T) { + tests := []struct { + input string + want Mode + wantErr bool + }{ + {input: "", want: ModeStrict}, + {input: "strict", want: ModeStrict}, + {input: "on", want: ModeStrict}, + {input: "true", want: ModeStrict}, + {input: "known", want: ModeKnown}, + {input: "off", want: ModeOff}, + {input: "false", want: ModeOff}, + {input: "disabled", want: ModeOff}, + {input: " OFF ", want: ModeOff}, + {input: "banana", want: ModeStrict, wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + got, err := ParseMode(tc.input) + if tc.wantErr != (err != nil) { + t.Fatalf("ParseMode(%q) error = %v, wantErr %v", tc.input, err, tc.wantErr) + } + if got != tc.want { + t.Errorf("ParseMode(%q) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} + +func TestResolveMode(t *testing.T) { + tests := []struct { + name string + noRedact bool + env string + profile string + want Mode + wantErr bool + }{ + {name: "default is strict", want: ModeStrict}, + {name: "flag wins over everything", noRedact: true, env: "strict", profile: "strict", want: ModeOff}, + {name: "environment wins over the profile", env: "known", profile: "off", want: ModeKnown}, + {name: "profile applies when the environment is unset", profile: "off", want: ModeOff}, + {name: "invalid environment value is an error", env: "banana", want: ModeStrict, wantErr: true}, + {name: "invalid profile value is an error", profile: "banana", want: ModeStrict, wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(EnvRedact, tc.env) + + got, err := ResolveMode(tc.noRedact, tc.profile) + if tc.wantErr != (err != nil) { + t.Fatalf("ResolveMode() error = %v, wantErr %v", err, tc.wantErr) + } + if got != tc.want { + t.Errorf("ResolveMode() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestNilRedactorIsDisabled(t *testing.T) { + var r *Redactor + + if r.Enabled() { + t.Error("Enabled() = true, want false for a nil Redactor") + } + if got := r.Apply("anything"); got != "anything" { + t.Errorf("Apply() = %v, want the value unchanged", got) + } + if r.Count() != 0 { + t.Errorf("Count() = %d, want 0", r.Count()) + } +} From 2453a5517265b9105087f9d6b2e1f65884d61809 Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Fri, 14 Aug 2026 10:50:41 -0600 Subject: [PATCH 2/3] Update SKILL.md --- skills/tfctl/SKILL.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/skills/tfctl/SKILL.md b/skills/tfctl/SKILL.md index 3508b3d..17e379d 100644 --- a/skills/tfctl/SKILL.md +++ b/skills/tfctl/SKILL.md @@ -140,14 +140,19 @@ tfctl api schema search "KEYWORD" --json # returns operationIds tfctl api schema get OPERATION_ID # full OpenAPI schema (large response — only call when needed) ``` +### Secret Redaction + +By default, tfctl will redact the output of sensitive values from `api` command output, which includes artifact download URLs, log URLs, tokens, private SSH keys, and anything that looks like a token. This includes --json and --jq output. When extracting a secret that you need, use the --no-redact global flag to disable redaction for a single request. + ## Output flags -| Need | Flag | -|-------------------|------------------| -| Filter / extract | `--jq ''` | -| Full JSON | `--json` | -| Render for human | `--markdown` | -| Audit a mutation | `--dry-run` | +| Need | Flag | +|----------------------|------------------| +| Filter / extract | `--jq ''` | +| Full JSON | `--json` | +| Render for human | `--markdown` | +| Audit a mutation | `--dry-run` | +| Don't redact secrets | `--no-redact` | `--jq` implies `--json`. Don't pass both. Always pass one explicitly — don't rely on auto-detect. From 3806e4b0bf8bb6cd33f4caa9efaf7d9aafbf069f Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Fri, 14 Aug 2026 11:23:27 -0600 Subject: [PATCH 3/3] Update SKILL.md --- skills/tfctl/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/tfctl/SKILL.md b/skills/tfctl/SKILL.md index 17e379d..0420473 100644 --- a/skills/tfctl/SKILL.md +++ b/skills/tfctl/SKILL.md @@ -142,7 +142,7 @@ tfctl api schema get OPERATION_ID # full OpenAPI schema (large respon ### Secret Redaction -By default, tfctl will redact the output of sensitive values from `api` command output, which includes artifact download URLs, log URLs, tokens, private SSH keys, and anything that looks like a token. This includes --json and --jq output. When extracting a secret that you need, use the --no-redact global flag to disable redaction for a single request. +By default, tfctl will redact the output of sensitive values from `api` command output, which includes artifact download URLs, log URLs, tokens, private SSH keys, and some unknown things such as variable values that match a token heuristic. This includes --json and --jq output. When extracting a secret that you need, use the --no-redact global flag to disable redaction for a single request. ## Output flags