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 3ee26d2..43584b2 100644 Binary files a/assets/tfctl.png and b/assets/tfctl.png differ 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()) + } +} diff --git a/skills/tfctl/SKILL.md b/skills/tfctl/SKILL.md index 3508b3d..0420473 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 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 -| 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.