From f9da3cd514b5e94b6c06c8a9e5d053d8bafb8f0c Mon Sep 17 00:00:00 2001 From: Braden Ream <51544548+Bradenream@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:37:18 -0400 Subject: [PATCH 1/3] fix: hide camelCase secrets in --dry-run and --debug output --dry-run and --debug print request and response bodies to stderr, and hid only a fixed list of lowercase names: password, secret, token, api_key and a few more. The Voiceflow API names its fields in camelCase, so most of the secrets it carries printed in full: - integration credentials: apiKeySecret, keySecret, secretKey, oauthSecretKey, webhookSecret, webhookSecretKey, authHeaderValue - a secret's value in secret create and set-value - Authorization values in API tool and MCP server headers Coding agents keep stderr in their transcripts. The rule now comes from a survey of all 868 property names in the OpenAPI spec: - Names are compared without case or separators. Those ending in secret, secretkey, password, token, apikey or privatekey are hidden, which covers every name the old list had. Counts like maxTokens and identifiers like accountSid, apiKeySid and keyId stay visible. - credentials is hidden whole. - A {key, value} pair whose key names a credential, such as an Authorization header, hides its value. - On the secret endpoints, defaultValue and value are hidden. - Booleans, numbers and null are never hidden. The rule lives in internal/client/redact.go. diagnostics.go calls it from redactJSON and from both request-body previews. --- internal/client/diagnostics.go | 23 ++---- internal/client/redact.go | 137 +++++++++++++++++++++++++++++++++ internal/client/redact_test.go | 91 ++++++++++++++++++++++ test/redaction.test.ts | 124 +++++++++++++++++++++++++++++ 4 files changed, 358 insertions(+), 17 deletions(-) create mode 100644 internal/client/redact.go create mode 100644 internal/client/redact_test.go create mode 100644 test/redaction.test.ts diff --git a/internal/client/diagnostics.go b/internal/client/diagnostics.go index 8136e648..a6d869cc 100644 --- a/internal/client/diagnostics.go +++ b/internal/client/diagnostics.go @@ -56,19 +56,6 @@ var sensitiveHeaderSuffixes = []string{ "-token", } -// sensitiveJSONKeys lists JSON field names that should be redacted. -var sensitiveJSONKeys = map[string]bool{ - "password": true, - "secret": true, - "token": true, - "access_token": true, - "refresh_token": true, - "api_key": true, - "apikey": true, - "private_key": true, - "client_secret": true, -} - // isSensitiveHeader returns true if the header key matches a sensitive pattern. func isSensitiveHeader(key string) bool { lower := strings.ToLower(key) @@ -105,10 +92,12 @@ func redactJSON(v interface{}, depth int) interface{} { } switch val := v.(type) { case map[string]interface{}: + // Which values are hidden, and why: see redact.go. + credentialPair := isCredentialPair(val) out := make(map[string]interface{}, len(val)) for k, child := range val { - if sensitiveJSONKeys[strings.ToLower(k)] { - out[k] = "[REDACTED]" + if hidesValue(k, child) || (credentialPair && k == "value") { + out[k] = redacted } else { out[k] = redactJSON(child, depth+1) } @@ -204,7 +193,7 @@ func (c *DebugClient) Do(req *http.Request) (*http.Response, error) { if req.Body != nil { bodyData, restored := readAndRestoreBody(req.Body) req.Body = restored - fmt.Fprintf(c.Stderr, "[DEBUG] Request Body:\n %s\n", redactBody(bodyData)) + fmt.Fprintf(c.Stderr, "[DEBUG] Request Body:\n %s\n", redactRequestBody(req.URL.Path, bodyData)) } // Execute @@ -240,7 +229,7 @@ func (c *DryRunClient) Do(req *http.Request) (*http.Response, error) { fmt.Fprintf(c.Stderr, "[DRY-RUN] Headers:\n%s", formatHeaders(redactHeaders(req.Header))) if req.Body != nil { bodyData, _ := readAndRestoreBody(req.Body) - fmt.Fprintf(c.Stderr, "[DRY-RUN] Body:\n %s\n", redactBody(bodyData)) + fmt.Fprintf(c.Stderr, "[DRY-RUN] Body:\n %s\n", redactRequestBody(req.URL.Path, bodyData)) } fmt.Fprintf(c.Stderr, "[DRY-RUN] Network call skipped.\n") diff --git a/internal/client/redact.go b/internal/client/redact.go new file mode 100644 index 00000000..b03e93e5 --- /dev/null +++ b/internal/client/redact.go @@ -0,0 +1,137 @@ +// This file is not generated by Speakeasy. It decides which values --dry-run +// and --debug hide when they print request and response bodies. +// +// The generated rule hid a fixed list of lowercase names: password, secret, +// token, api_key and a few more. The Voiceflow API names its fields in +// camelCase, so most of the secrets it carries printed in full: apiKeySecret, +// keySecret, secretKey, oauthSecretKey, webhookSecret, webhookSecretKey and +// authHeaderValue in integration credentials, a secret's value in secret +// create and set-value, and Authorization values in API tool and MCP server +// headers. Coding agents keep stderr in their transcripts, so a dry run or a +// debug run leaked them. +// +// The rule is fitted to a survey of all 868 property names in the OpenAPI spec +// (.speakeasy/out.openapi.yaml): +// +// - Names are compared without case or separators, so apiKey, api_key and +// API-KEY are one name. A name that ends in secret, secretkey, password, +// token, apikey or privatekey holds a secret, which covers every name the +// generated list had. Counts such as maxTokens end in "tokens", and +// identifiers such as accountSid, apiKeySid, keyId and accessTokenID end in +// none of them, so they stay visible. +// - credentials is hidden whole. It holds an integration's secrets, some +// under names, such as authHeaderValue, that no general rule would catch. +// - A {key, value} pair, the shape of API tool and MCP server headers and of +// query parameters, hides its value when the key names a credential, such +// as Authorization or X-Api-Key. +// - The secret endpoints carry the secret in generic fields: defaultValue on +// create and value on set-value. Those are hidden on requests to them. +// +// Booleans, numbers and null are never hidden: hasPassword says whether a +// password is set, not what it is. + +package client + +import ( + "encoding/json" + "strings" + "unicode" +) + +// redacted replaces every hidden value. +const redacted = "[REDACTED]" + +// secretNameSuffixes end the normalized name of every field holding a secret. +var secretNameSuffixes = []string{"secret", "secretkey", "password", "token", "apikey", "privatekey"} + +// credentialContainers are hidden whole, whatever they hold. +var credentialContainers = map[string]bool{"credential": true, "credentials": true} + +// secretEndpointFields hold the secret itself on the secret endpoints. +var secretEndpointFields = []string{"defaultValue", "value"} + +// normalizeName lowercases a field name and drops separators. +func normalizeName(name string) string { + return strings.Map(func(r rune) rune { + switch r { + case '_', '-', '.', ' ': + return -1 + } + return unicode.ToLower(r) + }, name) +} + +// namesASecret reports whether a field of this name holds a secret. +func namesASecret(name string) bool { + normalized := normalizeName(name) + if credentialContainers[normalized] { + return true + } + for _, suffix := range secretNameSuffixes { + if strings.HasSuffix(normalized, suffix) { + return true + } + } + return false +} + +// canHoldSecret reports whether a decoded JSON value could be a secret: a +// boolean, a number or null cannot. +func canHoldSecret(value interface{}) bool { + switch value.(type) { + case bool, float64, json.Number, nil: + return false + } + return true +} + +// hidesValue reports whether redactJSON hides the value of field name. +func hidesValue(name string, value interface{}) bool { + return canHoldSecret(value) && namesASecret(name) +} + +// isCredentialPair reports whether obj is a {key, value} pair, such as a header +// or a query parameter, whose key names a credential. +func isCredentialPair(obj map[string]interface{}) bool { + key, ok := obj["key"].(string) + if !ok { + return false + } + if _, hasValue := obj["value"]; !hasValue { + return false + } + return isSensitiveHeader(key) || namesASecret(key) +} + +// isSecretEndpoint reports whether path is one of the secret endpoints, such +// as /v2/stable/secret or /v1/stable/secret/{secretID}/value. +func isSecretEndpoint(path string) bool { + for _, segment := range strings.Split(path, "/") { + if segment == "secret" { + return true + } + } + return false +} + +// redactRequestBody is redactBody for a request to path. On the secret +// endpoints it also hides the generic fields that carry the secret itself. +func redactRequestBody(path string, body []byte) string { + if !isSecretEndpoint(path) { + return redactBody(body) + } + var fields map[string]interface{} + if err := json.Unmarshal(body, &fields); err != nil { + return redactBody(body) + } + for _, name := range secretEndpointFields { + if value, ok := fields[name]; ok && canHoldSecret(value) { + fields[name] = redacted + } + } + hidden, err := json.Marshal(fields) + if err != nil { + return redactBody(body) + } + return redactBody(hidden) +} diff --git a/internal/client/redact_test.go b/internal/client/redact_test.go new file mode 100644 index 00000000..838451f1 --- /dev/null +++ b/internal/client/redact_test.go @@ -0,0 +1,91 @@ +package client + +import ( + "strings" + "testing" +) + +// The names come from a survey of the OpenAPI spec; see redact.go. +func TestNamesASecret(t *testing.T) { + secrets := []string{ + // integration credentials + "apiKey", "apiKeySecret", "keySecret", "secretKey", "oauthSecretKey", "webhookSecret", "webhookSecretKey", "credentials", + // common spellings, whatever the case or separator + "password", "secret", "token", "accessToken", "refresh_token", "clientSecret", "client_secret", "API-KEY", "privateKey", + } + for _, name := range secrets { + if !namesASecret(name) { + t.Errorf("namesASecret(%q) = false, want true", name) + } + } + + visible := []string{ + // identifiers that sit next to secrets + "accountSid", "apiKeySid", "keyId", "clientId", "appId", "accessTokenID", "authHeaderName", "secretIDs", "secretNameToId", + // token counts + "maxTokens", "queryTokens", "answerTokens", "tokens", + // ordinary fields + "key", "value", "name", "sessionID", "authType", "translationKey", "s3Key", + } + for _, name := range visible { + if namesASecret(name) { + t.Errorf("namesASecret(%q) = true, want false", name) + } + } +} + +// The generated rule hid exactly these names. None may become visible. +func TestEveryNameTheGeneratedListHidStaysHidden(t *testing.T) { + for _, name := range []string{"password", "secret", "token", "access_token", "refresh_token", "api_key", "apikey", "private_key", "client_secret"} { + if !namesASecret(name) { + t.Errorf("namesASecret(%q) = false; the generated list hid it", name) + } + } +} + +func TestRedactBodyHidesSecretsAndKeepsTheRest(t *testing.T) { + body := `{ + "integration": "twilio", + "credentials": {"apiKeySid": "SK1", "apiKeySecret": "canary-twilio", "accountSid": "AC1"}, + "headers": [ + {"key": "Authorization", "value": "Bearer canary-header"}, + {"key": "x-api-key", "value": "canary-api-key-header"}, + {"key": "X-Region", "value": "eu-west"} + ], + "settings": {"clientSecret": "canary-nested", "hasPassword": true, "maxTokens": 512} + }` + + out := redactBody([]byte(body)) + + for _, secret := range []string{"canary-twilio", "canary-header", "canary-api-key-header", "canary-nested"} { + if strings.Contains(out, secret) { + t.Errorf("%q leaked:\n%s", secret, out) + } + } + for _, kept := range []string{`"integration": "twilio"`, `"X-Region"`, `"eu-west"`, `"hasPassword": true`, `"maxTokens": 512`} { + if !strings.Contains(out, kept) { + t.Errorf("%s was hidden:\n%s", kept, out) + } + } +} + +func TestRedactRequestBodyHidesTheValueOnSecretEndpoints(t *testing.T) { + cases := []struct { + path, body, secret, kept string + }{ + {"/v2/stable/secret", `{"name":"STRIPE_KEY","defaultValue":"canary-create","visibility":"masked"}`, "canary-create", "STRIPE_KEY"}, + {"/v1/stable/secret/abc123/value", `{"value":"canary-set","environmentAlias":"main"}`, "canary-set", `"environmentAlias": "main"`}, + } + for _, tc := range cases { + out := redactRequestBody(tc.path, []byte(tc.body)) + if strings.Contains(out, tc.secret) || !strings.Contains(out, tc.kept) { + t.Errorf("%s: got\n%s\nwant %q hidden and %q kept", tc.path, out, tc.secret, tc.kept) + } + } + + // Elsewhere these fields are ordinary: a variable's default stays visible. + out := redactRequestBody("/v1/stable/variable", []byte(`{"name":"plan","defaultValue":"free"}`)) + if !strings.Contains(out, `"defaultValue": "free"`) { + t.Errorf("defaultValue was hidden outside the secret endpoints:\n%s", out) + } +} diff --git a/test/redaction.test.ts b/test/redaction.test.ts new file mode 100644 index 00000000..b3d145c1 --- /dev/null +++ b/test/redaction.test.ts @@ -0,0 +1,124 @@ +// Tests for what --dry-run and --debug hide when they print request and +// response bodies (internal/client/redact.go). +// +// The redaction rule only matched lowercase names such as api_key, while the +// Voiceflow API names its secrets in camelCase, so integration credentials, +// secret values and Authorization headers printed in full to stderr, where a +// coding agent keeps them. Each case below sends a canary in a secret field and +// checks it never reaches stderr, while the ordinary fields beside it do. +// +// --dry-run sends nothing; --debug talks to a local mock server. +// Requires: go build -o vf ./cmd/vf + +import { execa } from 'execa'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const VF = path.resolve(__dirname, '..', 'vf'); + +// Every variable that puts the CLI into agent mode. Mirrors the list in +// internal/output/agentmode.go; a stray one on the host would change the output. +const AGENT_ENV_VARS = [ + 'CLAUDECODE', 'CLAUDE_CODE', 'CURSOR_AGENT', 'CODEX', 'AIDER', 'CLINE', + 'WINDSURF_AGENT', 'GITHUB_COPILOT', 'AMAZON_Q', 'GEMINI_CODE_ASSIST', + 'SRC_CODY', 'FORCE_AGENT_MODE', +]; + +let home: string; +let server: http.Server; +let serverURL: string; + +beforeAll(async () => { + // vf keeps credentials under HOME; an empty one keeps the developer's out. + home = fs.mkdtempSync(path.join(os.tmpdir(), 'vf-redaction-home-')); + + // Answers every request with an API tool whose header carries a token, as + // the API returns a tool's headers the way they were saved. + server = http.createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + apiTools: [{ id: 't1', name: 'visible-tool', headers: [{ key: 'Authorization', value: 'Bearer canary-response' }] }], + })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + serverURL = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(home, { recursive: true, force: true }); +}); + +function vf(args: string[]) { + const env: Record = Object.fromEntries(AGENT_ENV_VARS.map((name) => [name, undefined])); + env.HOME = home; + return execa({ reject: false, timeout: 20_000, stdin: 'ignore', env, extendEnv: true })(VF, [...args, '--token', 'vfp_x']); +} + +describe('--dry-run hides secrets and keeps the rest', () => { + const cases: Array<[name: string, args: string[], secret: string, kept: string]> = [ + ['an integration credential', + ['integration', 'connect', '--project-id', 'p', '--integration', 'twilio', '--body-param.twilio', + '{"integration":"twilio","credentials":{"apiKeySid":"SK1","apiKeySecret":"canary-credential","accountSid":"AC1"}}'], + 'canary-credential', '"integration": "twilio"'], + ['an Authorization header', + ['mcp-server', 'create', '--project-id', 'p', '--environment-alias', 'main', '--name', 'n', '--url', 'https://mcp.example.com', + '--headers', '[{"key":"Authorization","value":"Bearer canary-header"},{"key":"X-Region","value":"eu-west"}]'], + 'canary-header', 'eu-west'], + ["a secret's value on create", + ['secret', 'create', '--project-id', 'p', '--name', 'STRIPE_KEY', '--default-value', 'canary-create'], + 'canary-create', 'STRIPE_KEY'], + ["a secret's value on set-value", + ['secret', 'set-value', '--project-id', 'p', '--secret-id', 's1', '--value', 'canary-set', '--environment-alias', 'main', + '--version-variant', 'draft'], + 'canary-set', '"environmentAlias": "main"'], + ]; + + for (const [name, args, secret, kept] of cases) { + it(name, async () => { + const result = await vf([...args, '--dry-run']); + + // Some of these operations succeed with 201, and on master their dry runs + // exit 1 after the preview, so the preview is what is checked. + expect(result.stderr).toContain('[DRY-RUN] Body:'); + expect(result.stderr).not.toContain(secret); + expect(result.stderr).toContain(kept); + }); + } +}); + +/** + * One body that --debug printed. The mock's answers are not complete API + * objects, so the command may still fail afterwards; only what --debug printed + * is checked. + */ +function debugBody(stderr: string, title: 'Request Body' | 'Response Body'): string { + const match = stderr.match(new RegExp(`\\[DEBUG\\] ${title}:\\n((?:[ \\t].*\\n?)*)`)); + expect(match, `no ${title} in:\n${stderr}`).not.toBeNull(); + return match![1]!; +} + +describe('--debug hides secrets in requests and responses', () => { + it("hides a secret's value in the request body", async () => { + const result = await vf([ + 'secret', 'set-value', '--project-id', 'p', '--secret-id', 's1', '--value', 'canary-debug', '--environment-alias', 'main', + '--version-variant', 'draft', '--debug', '--server-url', serverURL, + ]); + + const body = debugBody(result.stderr, 'Request Body'); + expect(body).not.toContain('canary-debug'); + expect(body).toContain('"environmentAlias": "main"'); + }); + + it('hides an Authorization header in the response body', async () => { + const result = await vf(['api-tool', 'list', '--project-id', 'p', '--environment-alias', 'main', '--debug', '--server-url', serverURL]); + + const body = debugBody(result.stderr, 'Response Body'); + expect(body).not.toContain('canary-response'); + expect(body).toContain('visible-tool'); + }); +}); From aaebae601aa7ceb29b239fa8c313468b0c923d0a Mon Sep 17 00:00:00 2001 From: Braden Ream <51544548+Bradenream@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:47:47 -0400 Subject: [PATCH 2/3] fix: keep a credential pair's null, boolean or number value as it is A {key, value} pair whose key names a credential, such as an Authorization header, had its value replaced whatever it held, so {"key": "Authorization", "value": null} printed as "[REDACTED]". That broke this change's own rule that booleans, numbers and null are never hidden, and changed the value's JSON type. The pair now hides a value only when it could hold a secret, as named fields already did. Copilot raised this in review. --- internal/client/diagnostics.go | 2 +- internal/client/redact_test.go | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/internal/client/diagnostics.go b/internal/client/diagnostics.go index a6d869cc..2e09b4c6 100644 --- a/internal/client/diagnostics.go +++ b/internal/client/diagnostics.go @@ -96,7 +96,7 @@ func redactJSON(v interface{}, depth int) interface{} { credentialPair := isCredentialPair(val) out := make(map[string]interface{}, len(val)) for k, child := range val { - if hidesValue(k, child) || (credentialPair && k == "value") { + if hidesValue(k, child) || (credentialPair && k == "value" && canHoldSecret(child)) { out[k] = redacted } else { out[k] = redactJSON(child, depth+1) diff --git a/internal/client/redact_test.go b/internal/client/redact_test.go index 838451f1..c66dcedb 100644 --- a/internal/client/redact_test.go +++ b/internal/client/redact_test.go @@ -69,6 +69,27 @@ func TestRedactBodyHidesSecretsAndKeepsTheRest(t *testing.T) { } } +// A credential pair hides only a value that could be a secret. A null, a +// boolean or a number keeps its value and its JSON type. +func TestRedactBodyKeepsACredentialPairsNonSecretValue(t *testing.T) { + body := `{"headers": [ + {"key": "Authorization", "value": null}, + {"key": "X-Api-Key", "value": false}, + {"key": "X-Auth-Token", "value": 7} + ]}` + + out := redactBody([]byte(body)) + + if strings.Contains(out, redacted) { + t.Errorf("a null, boolean or number was redacted:\n%s", out) + } + for _, kept := range []string{`"value": null`, `"value": false`, `"value": 7`} { + if !strings.Contains(out, kept) { + t.Errorf("%s was changed:\n%s", kept, out) + } + } +} + func TestRedactRequestBodyHidesTheValueOnSecretEndpoints(t *testing.T) { cases := []struct { path, body, secret, kept string From 4a8260a4de872c2642ff5bd764e9af888f904e6e Mon Sep 17 00:00:00 2001 From: Braden Ream <51544548+Bradenream@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:13:41 -0400 Subject: [PATCH 3/3] ci: re-run checks on master now that #37 has fixed its tests