From a6ded541b649f26799e787f2b8b7d47d12853059 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Mon, 3 Aug 2026 13:16:42 -0600 Subject: [PATCH 1/4] fix: ga 'api request rest' command --- src/commands/api/request/rest.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/commands/api/request/rest.ts b/src/commands/api/request/rest.ts index bf55aa3..3408c62 100644 --- a/src/commands/api/request/rest.ts +++ b/src/commands/api/request/rest.ts @@ -60,7 +60,6 @@ export class Rest extends SfCommand { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); public static readonly examples = messages.getMessages('examples'); - public static state = 'beta'; public static enableJsonFlag = false; public static readonly flags = { 'target-org': Flags.requiredOrg(), @@ -122,7 +121,7 @@ export class Rest extends SfCommand { // @ts-expect-error users _could_ put one of these in their file without knowing it's wrong - TS is smarter than users here :) if (!methodOptions.includes(method)) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - throw new SfError(`"${method}" must be one of ${methodOptions.join(', ')}`); + throw new SfError(`"${method as string}" must be one of ${methodOptions.join(', ')}`); } // body can be undefined; // if we have a --body @myfile.json, read the file From a6d3fe41e98b39095a75e0b1ebd246e9029621f9 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Mon, 3 Aug 2026 13:18:02 -0600 Subject: [PATCH 2/4] chore: quick undo --- src/commands/api/request/rest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/api/request/rest.ts b/src/commands/api/request/rest.ts index 3408c62..aca2874 100644 --- a/src/commands/api/request/rest.ts +++ b/src/commands/api/request/rest.ts @@ -121,7 +121,7 @@ export class Rest extends SfCommand { // @ts-expect-error users _could_ put one of these in their file without knowing it's wrong - TS is smarter than users here :) if (!methodOptions.includes(method)) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - throw new SfError(`"${method as string}" must be one of ${methodOptions.join(', ')}`); + throw new SfError(`"${method}" must be one of ${methodOptions.join(', ')}`); } // body can be undefined; // if we have a --body @myfile.json, read the file From 9448bc7b802f236edeedb3f9630e08c7f4945430 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Mon, 3 Aug 2026 14:41:50 -0600 Subject: [PATCH 3/4] fix: redact Authorization header from error output on network failures --- src/shared/shared.ts | 66 +++++++++------ test/commands/api/request/rest/rest.test.ts | 89 +++++++++++++++++++++ 2 files changed, 131 insertions(+), 24 deletions(-) diff --git a/src/shared/shared.ts b/src/shared/shared.ts index 1c0edc5..728b780 100644 --- a/src/shared/shared.ts +++ b/src/shared/shared.ts @@ -23,6 +23,20 @@ import got from 'got'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-api', 'shared'); +export function redactError(error: unknown): unknown { + if (error instanceof Error && 'options' in error) { + const opts = error as Error & { options?: { headers?: Record } }; + if (opts.options?.headers) { + for (const key of Object.keys(opts.options.headers)) { + if (key.toLowerCase() === 'authorization') { + opts.options.headers[key] = '[REDACTED]'; + } + } + } + } + return error; +} + export async function sendAndPrintRequest(options: { streamFile?: string; url: URL; @@ -41,36 +55,40 @@ export async function sendAndPrintRequest(options: { // we just ensured it existed with the 'if' fileStream.on('finish', () => options.this.log(`File saved to ${options.streamFile!}`)); fileStream.on('error', (error) => { - throw SfError.wrap(error); + throw SfError.wrap(redactError(error)); }); responseStream.on('error', (error) => { - throw SfError.wrap(error); + throw SfError.wrap(redactError(error)); }); } else { - const res = options.options.method - ? // default to 'POST' if not specified - await got(options.url, options.options) - : await got.post(options.url, options.options); - // Print HTTP response status and headers. - if (options.include) { - options.this.log(`HTTP/${res.httpVersion} ${res.statusCode}`); - Object.entries(res.headers).map(([header, value]) => { - options.this.log( - `${ansis.blue.bold(header)}: ${Array.isArray(value) ? value.join(',') : value ?? ''}` - ); - }); - } - try { - // Try to pretty-print JSON response. - options.this.styledJSON(JSON.parse(res.body) as AnyJson); - } catch (err) { - // If response body isn't JSON, just print it to stdout. - options.this.log(res.body); - } + const res = options.options.method + ? // default to 'POST' if not specified + await got(options.url, options.options) + : await got.post(options.url, options.options); + // Print HTTP response status and headers. + if (options.include) { + options.this.log(`HTTP/${res.httpVersion} ${res.statusCode}`); + Object.entries(res.headers).map(([header, value]) => { + options.this.log( + `${ansis.blue.bold(header)}: ${Array.isArray(value) ? value.join(',') : value ?? ''}` + ); + }); + } + + try { + // Try to pretty-print JSON response. + options.this.styledJSON(JSON.parse(res.body) as AnyJson); + } catch (err) { + // If response body isn't JSON, just print it to stdout. + options.this.log(res.body); + } - if (res.statusCode >= 400) { - process.exitCode = 1; + if (res.statusCode >= 400) { + process.exitCode = 1; + } + } catch (error) { + throw SfError.wrap(redactError(error)); } } } diff --git a/test/commands/api/request/rest/rest.test.ts b/test/commands/api/request/rest/rest.test.ts index 2b6957b..227b38c 100644 --- a/test/commands/api/request/rest/rest.test.ts +++ b/test/commands/api/request/rest/rest.test.ts @@ -26,6 +26,7 @@ import nock = require('nock'); import { stubUx } from '@salesforce/sf-plugins-core'; import * as FormData from 'form-data'; import { getBodyContents, getHeaders, PostmanSchema, Rest } from '../../../../../src/commands/api/request/rest.js'; +import { redactError } from '../../../../../src/shared/shared.js'; describe('rest', () => { const $$ = new TestContext(); @@ -217,4 +218,92 @@ describe('rest', () => { expect(uxStub.styledJSON.args[0][0]).to.deep.equal(orgLimitsResponse); }); + + describe('token redaction on network errors', () => { + it('should not expose access token in error when request fails', async () => { + nock(testOrg.instanceUrl).get('/services/data/v56.0/limits').replyWithError('ECONNREFUSED'); + + try { + await Rest.run(['/services/data/v56.0/limits', '--target-org', 'test@hub.com']); + assert.fail('should have thrown'); + } catch (e) { + const fullError = JSON.stringify(e, Object.getOwnPropertyNames(e)); + expect(fullError).to.not.include(testOrg.accessToken); + } + }); + + it('should not expose access token in error cause when request fails', async () => { + nock(testOrg.instanceUrl).get('/services/data/v56.0/limits').replyWithError('connect ECONNREFUSED'); + + try { + await Rest.run(['/services/data/v56.0/limits', '--target-org', 'test@hub.com']); + assert.fail('should have thrown'); + } catch (e) { + const err = e as Error; + const causeStr = JSON.stringify(err.cause, Object.getOwnPropertyNames(err.cause as object)); + expect(causeStr).to.not.include(testOrg.accessToken); + } + }); + }); + + describe('redactError', () => { + it('should redact Authorization header from error options', () => { + const fakeToken = '00Dxx0000001gPL!FAKE_TOKEN'; + const error = new Error('request failed') as Error & { options: { headers: Record } }; + error.options = { + headers: { + Authorization: `Bearer ${fakeToken}`, + 'Content-Type': 'application/json', + }, + }; + + redactError(error); + + expect(error.options.headers.Authorization).to.equal('[REDACTED]'); + expect(error.options.headers['Content-Type']).to.equal('application/json'); + }); + + it('should handle case-insensitive Authorization header', () => { + const error = new Error('request failed') as Error & { options: { headers: Record } }; + error.options = { + headers: { + authorization: 'Bearer secret123', + }, + }; + + redactError(error); + + expect(error.options.headers.authorization).to.equal('[REDACTED]'); + }); + + it('should not redact non-sensitive headers', () => { + const error = new Error('request failed') as Error & { options: { headers: Record } }; + error.options = { + headers: { + Authorization: 'Bearer secret', + 'Content-Type': 'application/json', + Accept: 'application/xml', + }, + }; + + redactError(error); + + expect(error.options.headers.Authorization).to.equal('[REDACTED]'); + expect(error.options.headers['Content-Type']).to.equal('application/json'); + expect(error.options.headers.Accept).to.equal('application/xml'); + }); + + it('should be a no-op for errors without options', () => { + const error = new Error('plain error'); + const result = redactError(error); + expect(result).to.equal(error); + }); + + it('should be a no-op for non-Error values', () => { + const str = 'string error'; + expect(redactError(str)).to.equal(str); + expect(redactError(null)).to.equal(null); + expect(redactError(undefined)).to.equal(undefined); + }); + }); }); From 66a798aaf7c987abab152a84d1a20845e3835b89 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Mon, 3 Aug 2026 15:51:44 -0600 Subject: [PATCH 4/4] feat: add --json support to rest and graphql commands with structured response --- command-snapshot.json | 2 +- src/commands/api/request/graphql.ts | 10 +++++----- src/commands/api/request/rest.ts | 10 +++++----- src/shared/shared.ts | 31 +++++++++++++++++++++++++---- 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/command-snapshot.json b/command-snapshot.json index 63d771f..c88bd92 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -12,7 +12,7 @@ "command": "api:request:rest", "flagAliases": [], "flagChars": ["H", "S", "X", "b", "f", "i", "o"], - "flags": ["body", "file", "flags-dir", "header", "include", "method", "stream-to-file", "target-org"], + "flags": ["body", "file", "flags-dir", "header", "include", "json", "method", "stream-to-file", "target-org"], "plugin": "@salesforce/plugin-api" } ] diff --git a/src/commands/api/request/graphql.ts b/src/commands/api/request/graphql.ts index b44eb95..66d8ef5 100644 --- a/src/commands/api/request/graphql.ts +++ b/src/commands/api/request/graphql.ts @@ -19,16 +19,15 @@ import * as os from 'node:os'; import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; import { Messages, Org, SFDX_HTTP_HEADERS } from '@salesforce/core'; import { ProxyAgent } from 'proxy-agent'; -import { includeFlag, sendAndPrintRequest, streamToFileFlag } from '../../../shared/shared.js'; +import { type ApiResponseResult, includeFlag, sendAndPrintRequest, streamToFileFlag } from '../../../shared/shared.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-api', 'graphql'); -export default class Graphql extends SfCommand { +export default class Graphql extends SfCommand { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); public static readonly examples = messages.getMessages('examples'); - public static readonly state = 'beta'; public static readonly flags = { 'target-org': Flags.requiredOrg(), @@ -43,7 +42,7 @@ export default class Graphql extends SfCommand { }), }; - public async run(): Promise { + public async run(): Promise { const { flags } = await this.parse(Graphql); const org = flags['target-org']; @@ -69,6 +68,7 @@ export default class Graphql extends SfCommand { followRedirect: false, }; - await sendAndPrintRequest({ streamFile, url, options, include: flags.include, this: this }); + const result = await sendAndPrintRequest({ streamFile, url, options, include: flags.include, this: this }); + return result ?? { statusCode: 0, headers: {}, body: '' }; } } diff --git a/src/commands/api/request/rest.ts b/src/commands/api/request/rest.ts index aca2874..171ab44 100644 --- a/src/commands/api/request/rest.ts +++ b/src/commands/api/request/rest.ts @@ -20,7 +20,7 @@ import { Flags, SfCommand } from '@salesforce/sf-plugins-core'; import { Messages, Org, SFDX_HTTP_HEADERS, SfError } from '@salesforce/core'; import { Args } from '@oclif/core'; import FormData from 'form-data'; -import { includeFlag, sendAndPrintRequest, streamToFileFlag } from '../../../shared/shared.js'; +import { type ApiResponseResult, includeFlag, sendAndPrintRequest, streamToFileFlag } from '../../../shared/shared.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-api', 'rest'); @@ -56,11 +56,10 @@ export type PostmanSchema = { body: RawPostmanSchema | FormDataPostmanSchema; }; -export class Rest extends SfCommand { +export class Rest extends SfCommand { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); public static readonly examples = messages.getMessages('examples'); - public static enableJsonFlag = false; public static readonly flags = { 'target-org': Flags.requiredOrg(), include: includeFlag, @@ -98,7 +97,7 @@ export class Rest extends SfCommand { }), }; - public async run(): Promise { + public async run(): Promise { const { flags, args } = await this.parse(Rest); const org = flags['target-org']; @@ -167,7 +166,8 @@ export class Rest extends SfCommand { followRedirect: false, }; - await sendAndPrintRequest({ streamFile, url, options, include: flags.include, this: this }); + const result = await sendAndPrintRequest({ streamFile, url, options, include: flags.include, this: this }); + return result ?? { statusCode: 0, headers: {}, body: '' }; } } diff --git a/src/shared/shared.ts b/src/shared/shared.ts index 728b780..704daeb 100644 --- a/src/shared/shared.ts +++ b/src/shared/shared.ts @@ -37,13 +37,19 @@ export function redactError(error: unknown): unknown { return error; } +export type ApiResponseResult = { + statusCode: number; + headers: Record; + body: AnyJson | string; +}; + export async function sendAndPrintRequest(options: { streamFile?: string; url: URL; options: Record; include: boolean; this: SfCommand; -}): Promise { +}): Promise { if (options.streamFile) { const responseStream = options.options.method ? got.stream(options.url, options.options) @@ -60,12 +66,15 @@ export async function sendAndPrintRequest(options: { responseStream.on('error', (error) => { throw SfError.wrap(redactError(error)); }); + + return undefined; } else { try { const res = options.options.method ? // default to 'POST' if not specified await got(options.url, options.options) : await got.post(options.url, options.options); + // Print HTTP response status and headers. if (options.include) { options.this.log(`HTTP/${res.httpVersion} ${res.statusCode}`); @@ -76,17 +85,31 @@ export async function sendAndPrintRequest(options: { }); } + let parsedBody: AnyJson | string; try { - // Try to pretty-print JSON response. - options.this.styledJSON(JSON.parse(res.body) as AnyJson); + parsedBody = JSON.parse(res.body) as AnyJson; + options.this.styledJSON(parsedBody); } catch (err) { - // If response body isn't JSON, just print it to stdout. + parsedBody = res.body; options.this.log(res.body); } if (res.statusCode >= 400) { process.exitCode = 1; } + + const responseHeaders: Record = {}; + for (const [header, value] of Object.entries(res.headers)) { + if (value !== undefined) { + responseHeaders[header] = value; + } + } + + return { + statusCode: res.statusCode, + headers: responseHeaders, + body: parsedBody, + }; } catch (error) { throw SfError.wrap(redactError(error)); }