Skip to content
Open
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
5 changes: 5 additions & 0 deletions src/cli-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ const ENVIRONMENT: CliEnvironment = {
required: false,
description: 'Override the Elastic Cloud admin API base URL',
},
{
name: 'ELASTIC_DEBUG',
required: false,
description: 'Set to 1 to include HTTP request and response details',
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to also add a --verbose/-v flag so an env var isn't the only way to do this.

],
configFiles: [
{ path: '~/.elasticrc.yml', required: false, description: 'Primary config file (recommended)' },
Expand Down
13 changes: 12 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,22 @@ if (hasGlobalFlags) {
.option('--output-fields <list>', 'comma-separated list of fields to include in output (dot-notation supported)')
.option('--output-template <string>', 'Mustache-like template for custom text output (e.g. "{{id}}: {{name}}")')
}
program.option('--json', 'output as JSON')
program
.option('--json', 'output as JSON')
.option('--debug', 'print HTTP request and response details')
.option('-v, --verbose', 'print HTTP request and response details')

// preAction hook (skipped for --help paths since the hook never fires)
if (!wantsHelp) {
program.hook('preAction', async (thisCommand, actionCommand) => {
const { debug, verbose, json } = thisCommand.opts()
const cliDebugEnabled = debug === true || verbose === true
if (cliDebugEnabled || process.env['ELASTIC_DEBUG'] === '1') {
const { setHttpDebugEnabled, setHttpDebugJsonMode } = await import('./lib/http.js')
setHttpDebugEnabled(cliDebugEnabled)
setHttpDebugJsonMode(json === true)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm trying to understand the code here....

  1. the await import(...) here looks like it avoids loading the module unless debugging is on, BUT you also added the same to the factory, so it's always loaded anyway. https://github.com/elastic/cli/pull/485/changes#diff-399d91c5730fe8c956a45beb380a60404c1fcea16640f3a5f58b82e052aebfda
  2. The if also repeats the ELASTIC_DEBUG check that already lives in isHttpDebugEnabled().

a regular import and two unconditional calls do the same thing in less code and allows for better readability and maintenance:

setHttpDebugEnabled(debug === true || verbose === true)
setHttpDebugJsonMode(json === true)

const skipActionNames: ReadonlySet<string> = new Set(['version', 'completion', '__complete', 'status'])
if (skipActionNames.has(actionCommand.name())) return
// Groups with no sub-command will just call group.help() — no real action fires.
Expand Down
2 changes: 2 additions & 0 deletions src/completion/complete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ export async function buildCompletionTree (rewrittenWords: readonly string[]): P
root.option('--use-context <name>', 'override the active context from the config file')
root.option('--command-profile <name>', 'restrict available commands to a deployment profile')
root.option('--json', 'output as JSON')
root.option('--debug', 'print HTTP request and response details')
root.option('-v, --verbose', 'print HTTP request and response details')
root.option('--output-fields <list>', 'comma-separated list of fields to include in output')
root.option('--output-template <string>', 'Mustache-like template for custom text output')

Expand Down
5 changes: 3 additions & 2 deletions src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { extractSchemaArgs, validateSchemaArgs } from './lib/schema-args.ts'
import type { SchemaArgDefinition } from './lib/schema-args.ts'
import type { renderText as _RT, formatHandlerError as _FHE } from './output.ts'
import { pickFields, parseFieldList, applyTemplate, TemplateAgainstPrimitiveError } from './lib/output-transform.ts'
import { attachHttpDebug } from './lib/http.ts'
import { validateName, hasGlobalJsonFlag, configureErrorOutput, commandPath, isCommandAllowed, stripTransportMeta } from './factory-core.ts'
import type { OpaqueCommandHandle, JsonValue, CommandConfig, ParsedResult } from './factory-core.ts'
import { RawJsonValue } from './factory-core.ts'
Expand Down Expand Up @@ -613,7 +614,7 @@ export function defineCommand<T extends z.ZodType> (config: CommandConfig<T>): O
assert(handlerResult !== undefined, `command ${JSON.stringify(config.name)}: handler must return a JsonValue`)
if (isErrorResult(handlerResult)) {
if (jsonFormat === true) {
process.stderr.write(JSON.stringify(handlerResult) + '\n')
process.stderr.write(JSON.stringify(attachHttpDebug(handlerResult)) + '\n')
} else {
process.stderr.write(`Error: ${formatHandlerError(handlerResult)}\n`)
}
Expand Down Expand Up @@ -643,7 +644,7 @@ export function defineCommand<T extends z.ZodType> (config: CommandConfig<T>): O
}
}
} else if (jsonFormat === true) {
process.stdout.write(JSON.stringify(output) + '\n')
process.stdout.write(JSON.stringify(attachHttpDebug(output)) + '\n')
} else if (config.formatOutput !== undefined) {
process.stdout.write(config.formatOutput(output, parsed))
} else {
Expand Down
3 changes: 2 additions & 1 deletion src/lib/cloud-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import type { HttpMethod } from '../cloud/types.ts'
import { getResolvedConfig } from '../config/store.ts'
import { apiFetch, isHttpDebugEnabled } from './http.ts'
import { isLoopbackUrl } from './is-loopback-host.ts'
import { clientHeaders } from './meta.ts'

Expand Down Expand Up @@ -69,7 +70,7 @@ export class CloudClient {
init.body = JSON.stringify(params.body)
}

const response = await this._fetch(url, init)
const response = await apiFetch(this._fetch, url, init, { debug: isHttpDebugEnabled() })

if (!response.ok) {
const text = await response.text()
Expand Down
20 changes: 13 additions & 7 deletions src/lib/es-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { getResolvedConfig } from '../config/store.ts'
import { buildAuthHeader, type ApiKeyOrBasicAuth } from './auth.ts'
import { apiFetch, isHttpDebugEnabled } from './http.ts'
import { clientHeaders } from './meta.ts'

export interface EsRequestParams {
Expand Down Expand Up @@ -99,16 +100,21 @@ export class EsClient {
}

const isHead = params.method.toUpperCase() === 'HEAD'
const method = fetchBody !== undefined && params.method.toUpperCase() === 'GET' ? 'POST' : params.method

let response: Response
try {
const method = fetchBody !== undefined && params.method.toUpperCase() === 'GET' ? 'POST' : params.method
response = await this._fetch(url, {
method,
headers,
...(fetchBody !== undefined && { body: fetchBody }),
redirect: 'error',
})
response = await apiFetch(
this._fetch,
url,
{
method,
headers,
...(fetchBody !== undefined && { body: fetchBody }),
redirect: 'error',
},
{ debug: isHttpDebugEnabled() }
)
} catch (err) {
throw new EsConnectionError(err instanceof Error ? err.message : String(err))
}
Expand Down
271 changes: 271 additions & 0 deletions src/lib/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
/*
* Copyright Elasticsearch B.V. and contributors
* SPDX-License-Identifier: Apache-2.0
*/

import type { JsonValue } from '../factory-core.ts'

const REDACTED_HEADERS = new Set([
'authentication-info',
'authorization',
'cookie',
'proxy-authenticate',
'proxy-authentication-info',
'proxy-authorization',
'set-cookie',
'www-authenticate',
'x-api-key',
])

const REDACTED_VALUE = '(redacted)'

// Credential fields exposed by the Elasticsearch, Kibana, and Cloud APIs.
const REDACTED_JSON_FIELDS = new Set([
'accesskey',
'accesstoken',
'apikey',
'apikeys',
'authorization',
'clientsecret',
'clustercredentials',
'credential',
'credentials',
'encoded',
'enroltoken',
'enrollmenttoken',
'httpcakey',
'invitationtoken',
'invitationtokens',
'kerberosauthenticationresponsetoken',
'kerberosticket',
'langsmithapikey',
'nodescredentials',
'password',
'passwordhash',
'privatekey',
'refreshtoken',
'relaystate',
'secret',
'secretkey',
'secretparameters',
'secrets',
'secrettoken',
'securesettingspassword',
'sessionstate',
'serviceaccounttoken',
'servicetoken',
'transportkey',
'uninstalltoken',
])

const GENERIC_TOKEN_FIELDS = new Set(['token', 'tokens'])
const REDACTED_URL_FIELDS = new Set([...REDACTED_JSON_FIELDS, ...GENERIC_TOKEN_FIELDS, 'code', 'state'])

/**
* Per-request behavior for {@link apiFetch}.
*/
export interface FetchOptions {
debug?: boolean
}

let cliDebugEnabled = false
let jsonDebugMode = false
const bufferedDebugStatements: string[] = []

/**
* Enables or disables HTTP debugging for the current CLI process.
*/
export function setHttpDebugEnabled (enabled: boolean): void {
cliDebugEnabled = enabled
}

/**
* Returns whether HTTP debug output is enabled by CLI flag or environment.
*/
export function isHttpDebugEnabled (): boolean {
return cliDebugEnabled || process.env['ELASTIC_DEBUG'] === '1'
}

/**
* Buffers HTTP diagnostics for structured output instead of writing to stderr.
*/
export function setHttpDebugJsonMode (enabled: boolean): void {
jsonDebugMode = enabled
bufferedDebugStatements.length = 0
}

function writeDebugStatement (statement: string): void {
if (jsonDebugMode) {
bufferedDebugStatements.push(statement)
} else {
process.stderr.write(`${statement}\n`)
}
}

function writeHeaders (prefix: string, headers: RequestInit['headers']): void {
for (const [name, value] of new Headers(headers)) {
const printableValue = REDACTED_HEADERS.has(name.toLowerCase()) ? REDACTED_VALUE : value
writeDebugStatement(`${prefix} ${name}: ${printableValue}`)
}
}

function normalizedFieldName (name: string): string {
return name.replace(/[^a-z0-9]/gi, '').toLowerCase()
}

function redactJsonValue (value: unknown, redactGenericTokens: boolean): unknown {
if (Array.isArray(value)) {
return value.map(item => redactJsonValue(item, redactGenericTokens))
}
if (value === null || typeof value !== 'object') {
return value
}

return Object.fromEntries(
Object.entries(value).map(([name, fieldValue]) => {
const normalizedName = normalizedFieldName(name)
const isSensitive = REDACTED_JSON_FIELDS.has(normalizedName) ||
(redactGenericTokens && GENERIC_TOKEN_FIELDS.has(normalizedName))
return [
name,
isSensitive ? REDACTED_VALUE : redactJsonValue(fieldValue, redactGenericTokens),
]
})
)
}

function urlCarriesCredentialTokens (url: string): boolean {
let normalizedUrl: string
try {
normalizedUrl = new URL(url).pathname.toLowerCase()
} catch {
normalizedUrl = url.toLowerCase()
}
return [
'/credential',
'/enroll',
'/oauth',
'/oidc',
'/saml',
'/token',
'/tokens',
].some(pathPart => normalizedUrl.includes(pathPart))
}

function redactBody (body: string, url: string): string {
const redactGenericTokens = urlCarriesCredentialTokens(url)
try {
return JSON.stringify(redactJsonValue(JSON.parse(body), redactGenericTokens))
} catch {
const lines = body.split('\n')
if (lines.length === 1) return body

try {
return lines
.map(line => line.trim().length === 0
? line
: JSON.stringify(redactJsonValue(JSON.parse(line), redactGenericTokens)))
.join('\n')
} catch {
return body
}
}
}

function redactUrl (url: string): string {
try {
const parsed = new URL(url)
let changed = false

if (parsed.username.length > 0 || parsed.password.length > 0) {
parsed.username = REDACTED_VALUE
parsed.password = REDACTED_VALUE
changed = true
}

const segments = parsed.pathname.split('/')
for (let index = 0; index < segments.length - 1; index += 1) {
if (segments[index]?.toLowerCase() === 'invitations') {
segments[index + 1] = REDACTED_VALUE
changed = true
}
}
parsed.pathname = segments.join('/')

for (const name of parsed.searchParams.keys()) {
if (REDACTED_URL_FIELDS.has(normalizedFieldName(name))) {
parsed.searchParams.set(name, REDACTED_VALUE)
changed = true
}
}

if (!changed) return url
return parsed.toString().replaceAll('%28redacted%29', REDACTED_VALUE)
} catch {
return url
}
}
Comment on lines +62 to +207

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue only asked for header redaction, and it says response bodies should be printed as-is.

There are two practical problems with keeping this:

  • It doesn't add protection. When a response contains a secret, the CLI already prints that response to stdout as the normal command output. Hiding it in the debug output doesn't stop the user from seeing it.
  • The field list has to be kept up to date by hand. Every time an API adds a new field that holds a secret, someone has to remember to add it to this list, or it silently stops being complete.

Please keep the header redaction (that part is exactly right) and remove REDACTED_JSON_FIELDS, redactJsonValue, urlCarriesCredentialTokens, redactBody, redactUrl, and their tests. If we decide we want body redaction later, it deserves its own PR where we can discuss the field list properly.

Alternatively a note to the --debug/ELASTIC_DEBUG help text like "output may contain sensitive data from request and response bodies; review before sharing." may be enough for now


/**
* Adds buffered HTTP diagnostics to a structured command result and clears the buffer.
*
* Object results keep their existing top-level shape. Primitive, array, or
* pre-existing `debug` results are wrapped under `result` to avoid data loss.
*/
export function attachHttpDebug (value: JsonValue): JsonValue {
const debug = bufferedDebugStatements.splice(0)
if (debug.length === 0) return value

if (value !== null && typeof value === 'object' && !Array.isArray(value) && !Object.hasOwn(value, 'debug')) {
return { ...value, debug }
}
return { result: value, debug }
}

/**
* Executes an API request with optional request and response diagnostics.
*
* Credential-bearing headers are redacted case-insensitively. In text mode,
* diagnostics go to stderr. In JSON mode, they are buffered for
* {@link attachHttpDebug}. The response is cloned before inspection so callers
* can consume the original body.
*/
export async function apiFetch (
fetchImplementation: typeof fetch,
url: string,
init: RequestInit,
options: FetchOptions = {}
): Promise<Response> {
if (options.debug !== true) {
return fetchImplementation(url, init)
}
Comment on lines +239 to +241

@margaretjgu margaretjgu Jul 27, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three clients call apiFetch with the exact same option:

// es-client.ts, kibana-client.ts, cloud-client.ts
const response = await apiFetch(this._fetch, url, init, { debug: isHttpDebugEnabled() })

Since isHttpDebugEnabled lives in this module anyway, apiFetch can use it as the default when the caller doesn't say otherwise:

Suggested change
if (options.debug !== true) {
return fetchImplementation(url, init)
}
if ((options.debug ?? isHttpDebugEnabled()) !== true) {
return fetchImplementation(url, init)
}

Then each call site shrinks to apiFetch(this._fetch, url, init) and no longer needs to import isHttpDebugEnabled. The explicit { debug: true } / { debug: false } option still works as an override, so the existing tests in test/lib/http.test.ts don't need to change.

TLDR - when every caller of a function passes the same argument, that argument isn't really a choice the callers are making...it's a default the function should own...now we dont need to repeat the same thing three times and have only one import


writeDebugStatement(`> ${init.method ?? 'GET'} ${redactUrl(url)}`)
writeHeaders('>', init.headers)
if (typeof init.body === 'string') {
writeDebugStatement(redactBody(init.body, url))
}

let response: Response
try {
response = await fetchImplementation(url, init)
} catch (error) {
writeDebugStatement(`< Request failed: ${String(error)}`)
throw error
}

const statusText = response.statusText.length > 0 ? ` ${response.statusText}` : ''
writeDebugStatement(`< ${response.status}${statusText}`)
writeHeaders('<', response.headers)

try {
const body = await response.clone().text()
if (body.length > 0) {
writeDebugStatement(redactBody(body, url))
}
} catch (error) {
writeDebugStatement(`< Response body unavailable: ${String(error)}`)
}

return response
}
Loading