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 print HTTP request and response details to stderr',
},

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
9 changes: 8 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,18 @@ 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 to stderr')

// preAction hook (skipped for --help paths since the hook never fires)
if (!wantsHelp) {
program.hook('preAction', async (thisCommand, actionCommand) => {
if (thisCommand.opts().debug === true) {
const { setHttpDebugEnabled } = await import('./lib/http-debug.js')
setHttpDebugEnabled(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
1 change: 1 addition & 0 deletions src/completion/complete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ 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 to stderr')
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
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 { fetchWithHttpDebug } from './http-debug.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 fetchWithHttpDebug(this._fetch, url, init)

if (!response.ok) {
const text = await response.text()
Expand Down
5 changes: 3 additions & 2 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 { fetchWithHttpDebug } from './http-debug.ts'
import { clientHeaders } from './meta.ts'

export interface EsRequestParams {
Expand Down Expand Up @@ -99,11 +100,11 @@ 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, {
response = await fetchWithHttpDebug(this._fetch, url, {
method,
headers,
...(fetchBody !== undefined && { body: fetchBody }),
Expand Down
80 changes: 80 additions & 0 deletions src/lib/http-debug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Copyright Elasticsearch B.V. and contributors
* SPDX-License-Identifier: Apache-2.0
*/

const REDACTED_HEADERS = new Set([
'authorization',
'proxy-authorization',
'x-api-key',
'cookie',
'set-cookie',
])
Comment on lines +6 to +12

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.

We'll need to validate this list by scanning each API codebase to ensure these are the only values with the potential to leak secrets.


let cliDebugEnabled = false

/**
* 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'
}

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
process.stderr.write(`${prefix} ${name}: ${printableValue}\n`)

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.

This will violate the requirement that all stdout/stderr output be able to be parsed as valid JSON when --json is enabled on any command. This should be wrapped in a function that checks that flag and, if --json is enabled, it should push all debug statements to an array and include them in the JSON response somehow.

}
}

/**
* Executes a fetch call and writes opt-in request and response diagnostics to stderr.
*
* Credential-bearing headers are redacted case-insensitively. The response is
* cloned before its body is inspected so callers can consume the original body.
*/
export async function fetchWithHttpDebug (
fetchImplementation: typeof fetch,
url: string,
init: RequestInit
): Promise<Response> {
if (!isHttpDebugEnabled()) {
return fetchImplementation(url, init)
}

process.stderr.write(`> ${init.method ?? 'GET'} ${url}\n`)
writeHeaders('>', init.headers)
if (typeof init.body === 'string') {
process.stderr.write(`\n${init.body}\n`)
}

let response: Response
try {
response = await fetchImplementation(url, init)
} catch (error) {
process.stderr.write(`< Request failed: ${String(error)}\n`)
throw error
}

const statusText = response.statusText.length > 0 ? ` ${response.statusText}` : ''
process.stderr.write(`< ${response.status}${statusText}\n`)
writeHeaders('<', response.headers)

try {
const body = await response.clone().text()
if (body.length > 0) {
process.stderr.write(`\n${body}\n`)
}
} catch (error) {
process.stderr.write(`< Response body unavailable: ${String(error)}\n`)
}

return response
}

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.

On the whole, providing a single fetch wrapped implementation is correct for this use case, and may have other benefits besides debug logs in the future. This makes fetchWithHttpDebug a bit too specific of a function name. Rename the module src/lib/http.ts and the function apiFetch or sendRequest; something that differentiates it from the built-in fetch function. That function should take an options object that looks like:

interface FetchOptions {
  debug?: boolean
}

This gives us a place to add new options as we need them.

3 changes: 2 additions & 1 deletion src/lib/kibana-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import fs from 'node:fs'
import path from 'node:path'
import { getResolvedConfig } from '../config/store.ts'
import { buildAuthHeader, type ApiKeyOrBasicAuth } from './auth.ts'
import { fetchWithHttpDebug } from './http-debug.ts'
import { isLoopbackUrl } from './is-loopback-host.ts'
import { clientHeaders } from './meta.ts'

Expand Down Expand Up @@ -98,7 +99,7 @@ export class KibanaClient {
init.body = JSON.stringify(params.body)
}

const response = await this._fetch(url, init)
const response = await fetchWithHttpDebug(this._fetch, url, init)

if (!response.ok) {
const text = await response.text()
Expand Down
12 changes: 12 additions & 0 deletions test/cli-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,16 @@ describe('cli schema', () => {
assert.ok(Array.isArray(schema['namespaces']))
assert.ok((schema['namespaces'] as Array<{ segment?: string }>).some(ns => ns.segment === 'stack'))
})

it('documents the HTTP debug flag and environment variable', async () => {
const { code, stdout, stderr } = await runCliSchema()
assert.equal(code, 0, stderr)

const schema = JSON.parse(stdout) as {
globalOptions: Array<{ name: string }>
environment: { variables: Array<{ name: string }> }
}
assert.ok(schema.globalOptions.some(option => option.name === 'debug'))
assert.ok(schema.environment.variables.some(variable => variable.name === 'ELASTIC_DEBUG'))
})
})
14 changes: 14 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function makeProgram(): InstanceType<typeof Command> {
prog.option('--config-file <path>', 'path to a config file (default: ~/.elasticrc.yml)')
prog.option('--use-context <name>', 'override the active context from the config file')
prog.option('--json', 'output as JSON')
prog.option('--debug', 'print HTTP request and response details to stderr')
return prog
}

Expand All @@ -54,6 +55,13 @@ describe('elastic CLI -- global flags', () => {
assert.ok(!opt.required, '--json should be a boolean flag (no required value)')
})

it('registers --debug as a boolean flag', () => {
const prog = makeProgram()
const opt = prog.options.find((o) => o.long === '--debug')
assert.ok(opt != null, 'expected --debug option')
assert.ok(!opt.required, '--debug should be a boolean flag (no required value)')
})

it('registers --version as a boolean flag', () => {
const prog = makeProgram()
const opt = prog.options.find((o) => o.long === '--version')
Expand Down Expand Up @@ -86,6 +94,12 @@ describe('elastic CLI -- global flags', () => {
assert.equal(prog.opts()['json'], true)
})

it('parses --debug as true when provided', () => {
const prog = makeProgram()
prog.parse(['--debug'], { from: 'user' })
assert.equal(prog.opts()['debug'], true)
})

it('parses --config-file value correctly', () => {
const prog = makeProgram()
prog.parse(['--config-file', '/some/path.yml'], { from: 'user' })
Expand Down
6 changes: 6 additions & 0 deletions test/completion/complete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ describe('buildCompletionTree -- top-level commands', () => {
assert.ok(opt != null, 'expected --use-context global option')
})

it('exposes global --debug option on the root program', async () => {
const root = await buildCompletionTree([])
const opt = root.options.find(o => o.long === '--debug')
assert.ok(opt != null, 'expected --debug global option')
})

it('does not load the es subtree when the first word is unrelated', async () => {
const root = await buildCompletionTree(['cloud'])
const stack = root.commands.find(c => c.name() === 'stack')!
Expand Down
40 changes: 40 additions & 0 deletions test/lib/cloud-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getCloudClient, _testResetCloudClient } from '../../src/lib/cloud-clien
import { setResolvedConfig } from '../../src/config/store.ts'
import type { ResolvedConfig } from '../../src/config/types.ts'
import { clientHeaders } from '../../src/lib/meta.ts'
import { captureProcessOutput } from '../support/capture-output.ts'

afterEach(() => {
_testResetCloudClient()
Expand Down Expand Up @@ -115,6 +116,45 @@ describe('getCloudClient', () => {
})
})

it('honours ELASTIC_DEBUG and redacts the Cloud API key', async () => {
const previousDebug = process.env['ELASTIC_DEBUG']
process.env['ELASTIC_DEBUG'] = '1'
setResolvedConfig({
context: {
cloud: {
url: 'https://api.elastic-cloud.com',
auth: { api_key: 'cloud-secret' },
},
},
})
const client = getCloudClient()
client._testSetFetch((() =>
Promise.resolve(new Response('{"id":"deployment-1"}', {
status: 201,
statusText: 'Created',
headers: { 'content-type': 'application/json' },
}))
) as typeof fetch)

try {
const { stderr } = await captureProcessOutput(() =>
client.request({
method: 'POST',
path: '/api/v1/deployments',
body: { name: 'example' },
})
)
assert.match(stderr, /> POST https:\/\/api\.elastic-cloud\.com\/api\/v1\/deployments/)
assert.match(stderr, /> authorization: \(redacted\)/)
assert.match(stderr, /\{"name":"example"\}/)
assert.match(stderr, /< 201 Created/)
assert.doesNotMatch(stderr, /cloud-secret/)
} finally {
if (previousDebug === undefined) delete process.env['ELASTIC_DEBUG']
else process.env['ELASTIC_DEBUG'] = previousDebug
}
})

it('sends ApiKey authorization header', async () => {
setResolvedConfig({ context: { cloud: { url: 'https://api.elastic-cloud.com', auth: { api_key: 'secret-key' } } } })
const client = getCloudClient()
Expand Down
28 changes: 28 additions & 0 deletions test/lib/es-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { EsClient, EsResponseError, EsConnectionError, getEsClient, _testResetEs
import { setResolvedConfig } from '../../src/config/store.ts'
import type { ResolvedConfig } from '../../src/config/types.ts'
import { clientHeaders } from '../../src/lib/meta.ts'
import { captureProcessOutput } from '../support/capture-output.ts'

function makeApiKeyConfig (url: string, apiKey: string): ResolvedConfig {
return { context: { elasticsearch: { url, auth: { api_key: apiKey } } } }
Expand Down Expand Up @@ -303,6 +304,33 @@ describe('EsClient.request', () => {
)
})

it('honours ELASTIC_DEBUG without writing to stdout or exposing credentials', async () => {
const previousDebug = process.env['ELASTIC_DEBUG']
process.env['ELASTIC_DEBUG'] = '1'
const client = makeClient({ api_key: 'es-secret' })
client._testSetFetch((() =>
Promise.resolve(new Response('{"hits":[]}', {
status: 200,
headers: { 'content-type': 'application/json' },
}))
) as typeof fetch)

try {
const output = await captureProcessOutput(() =>
client.request({ method: 'POST', path: '/_search', body: { query: { match_all: {} } } })
)
assert.equal(output.stdout, '')
assert.match(output.stderr, /> POST http:\/\/localhost:9200\/_search/)
assert.match(output.stderr, /> authorization: \(redacted\)/)
assert.match(output.stderr, /\{"query":\{"match_all":\{\}\}\}/)
assert.match(output.stderr, /< 200/)
assert.doesNotMatch(output.stderr, /es-secret/)
} finally {
if (previousDebug === undefined) delete process.env['ELASTIC_DEBUG']
else process.env['ELASTIC_DEBUG'] = previousDebug
}
})

it('sets redirect to error', async () => {
const client = makeClient()
let capturedInit: RequestInit = {}
Expand Down
Loading