Redact sensitive values from command output - #101
Merged
brandonc merged 3 commits intoAug 14, 2026
Conversation
Some API responses carry credentials and tfctl prints them. A created token is returned once in full, and a state version carries download URLs that grant the state itself without a token. AGENTS.md already requires that a displayer payload never include credentials, noting that JSON output serializes the full payload rather than the displayed fields. JSONAPIDisplayer cannot honor that, because it carries whatever the server returned, and ExcludeColumns does not help: it feeds FieldTemplates for table and pretty output while outputJSON marshals the raw envelope. Add internal/pkg/redact and apply it in format.Outputter.Display, before the format is selected, so every format agrees and a --jq filter cannot reach a value that --json would have hidden. Cover Outputter.CopyRaw for bodies no displayer handles, such as plan JSON, and the --dry-run request preview, which otherwise echoes the value being set. Masking is controlled by the redact profile property, TFCTL_REDACT, or --no-redact, with modes strict (default), known, and off. Copying is on write, so a response with nothing to mask costs no allocation.
jordanenglish
force-pushed
the
feature/redact-sensitive-output
branch
from
August 7, 2026 00:17
d4af505 to
acc4c10
Compare
jordanenglish
marked this pull request as ready for review
August 7, 2026 00:20
shwetamurali
previously approved these changes
Aug 13, 2026
shwetamurali
left a comment
Collaborator
There was a problem hiding this comment.
Awesome, thanks for contributing this!
shwetamurali
approved these changes
Aug 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Some API responses carry credentials, and
tfctlprints them. A created token is returned once in full, and a state version carrieshosted-state-download-urlandhosted-json-state-download-url, which grant the state itself without a token. State holds every value Terraform wrote, marked sensitive or not.The caller was authorized to fetch these, so this is not a permission failure. The problem is that a credential outlives the moment it was needed: output persists in scrollback and shell history, is captured wholesale by CI logs, and is read into context by coding agents, which this project ships a skill for. The download URLs are the sharpest case, because they are bearer credentials, so a copy of the output is a copy of the access.
AGENTS.mdalready states the rule:JSONAPIDisplayercannot honor it, because it carries whatever the server returned.resource.ExcludeColumnsdoes not help either: it feedsFieldTemplates, which drives table and pretty output, whileoutputJSONmarshals the raw envelope.orderedFieldsthen appends every attribute that is not excluded, so a plaingetrenders these URLs too, not only--json.This adds
internal/pkg/redactand applies it informat.Outputter.Display, before the format is selected, so every format agrees and a--jqfilter cannot reach a value that--jsonwould have hidden. It coversOutputter.CopyRawfor bodies no displayer handles, such as plan JSON, and the--dry-runrequest preview. This is a hygiene layer in the same sense asinputguard, not a security boundary: #85 did it for telemetry paths and #60 covered transmission and file permissions, leaving stdout as the gap.Behavior
Three modes, set by the new
redactprofile property orTFCTL_REDACT. Precedence is--no-redact, thenTFCTL_REDACT, then the profile property, then the default.strict(default)knownoff--no-redactisofffor a single commandWhat counts as sensitive:
token,secret,private-ssh-key,encryption-password, and theauthorization/proxy-authorizationheadersdownload-url,upload-url, orlog-read-url, which covers state versions, configuration versions, plan exports, and plan or apply log readsvaluewhere the object sets"sensitive": true, or where the name the object gives itself indicates a credential, as invariables.db_password.value*.atlasv1.*, Vaulthv[sbr]., GitHubgh?_, AWSAKIA/ASIA, or a presigned URL carryingX-Amz-SignaturestrictThe shape rules are deliberately narrow. They recognize formats rather than guess at entropy, so an unnamed field holding an unrecognizable secret is not caught.
Notes:
nullstaysnullrather than becoming a placeholder, so output still shows that the API held the value back.oauth-token-idstill renders; masking it would break every workflow that needs the VCS connection.sensitivemarker is never masked, since it is what drives the declared-sensitive rule.(redacted), matching the styleProfile.Stringuses for the stored token. Angle brackets are avoided becauseencoding/jsonescapes them.redactvalue falls back tostrictand reports why, rather than failing every command.Example Output
A state version download URL is an opaque credential: it grants the state without a token. Before, it renders in
--json, in--jq, and in the default output. The URL is elided here:After:
The default output of a single
getis masked the same way, since it renders every attribute that is not excluded:A
--dry-runpreview no longer echoes the value being set:Names that only describe a credential are untouched, and an ordinary workspace renders exactly as before:
Tests
internal/pkg/redact: table tests for each rule in both directions. Values that must be masked, and values that must survive, includingoauth-token-id, thesensitivemarker, an ordinary URL, and a value the server already withheld.internal/pkg/format: a synthetic corpus of nine responses (state version download URLs, a created token, an upload URL, a declared-sensitive variable, six variables holding secrets nobody marked sensitive, an OAuth client secret, a log read URL, a workspace that must survive intact, and a raw plan JSON body). For each, the invariant is asserted in every output format, including the default format and a--jqfilter aimed directly at the attribute. Every credential in the corpus is invented: either a published documentation example or a literal that says it is not real.TestRedactCorpus_LeaksWithoutTheRedactorrenders the same corpus with no redactor and requires every credential to appear, so a corpus that was empty or misspelled cannot make the masking test pass vacuously.internal/commands/api: the command path against a routed test server for a state version, a created token, a raw JSON body, the--dry-runpreview, a sensitive header,--no-redact, and a body that cannot be parsed.internal/pkg/cmd: mode resolution across flag, environment, and profile, including the fallback on an unusable value.gofmt,golangci-lint run,go test ./..., andgo test ./... -raceclean.PR Checklist
npx changie newor install changie to prepare a new changelog entry for the next set of release notes.BUG FIXESfor the leak,ENHANCEMENTSfor theredactsetting and--no-redactflag, andNOTESdescribing the change to default output.--json— Force machine readable output to stdout. Does not apply to stderr.--markdown— Force markdown output to stdout. Does not apply to stderr.--dry-run— Don't make any actual writes or other mutations. Describe what would have changed to stderr.--quiet— Only render essential content.Displaybefore the format is selected, so--json,--markdown, pretty, table, and agent output are all covered and stay consistent with each other;--jqcannot reach around it.--dry-runpreviews are masked with the same rules and remain on stderr. The report of masked fields goes toErrUnessential(), so--quietsuppresses it; the warning that masking is off is essential and survives--quiet. Byte-for-byte raw output is preserved when nothing was masked.redactvalue is reported to the user on stderr rather than only logged, because it silently changes protection.make gen/screenshotif the root command output changes.--no-redactglobal flag, which changes root command output.Autocompletefield to positional arguments and flags to assist shell autocomplete.--no-redactis a boolean flag with no value to complete, and is registered inAutocompleteGlobalFlagsalongside the other global flags.Profile.Predictcompletes theredactproperty and its three values.PCI review checklist
I have documented a clear reason for, and description of, the change I am making.
If applicable, I've documented a plan to revert these changes if they require more than reverting the pull request.
redact = "off"in their profile or pass--no-redactper command.If applicable, I've documented the impact of any changes to security controls.
Examples of changes to security controls include using new access control methods, adding or removing logging pipelines, etc.
tfctlsends are byte-for-byte what they were before. It is a hygiene layer and not a security boundary: the API still decides what a token may read, and masking can be disabled per command or per profile, so it must not be relied on as an enforcement point. It does change default output: a script that reads a state version download URL or a newly created token out oftfctloutput will read(redacted)until it passes--no-redact. The stderr report names the masked field and the flag, and aNOTESchangelog entry records the change.