Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changes/unreleased/BUG FIXES-20260806-170000.yaml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .changes/unreleased/ENHANCEMENTS-20260806-170001.yaml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .changes/unreleased/NOTES-20260806-170002.yaml
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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=<name>`: 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 |
Expand Down
Binary file modified assets/tfctl.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 11 additions & 10 deletions cmd/tfctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}

Expand Down
53 changes: 47 additions & 6 deletions internal/commands/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
Loading