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 debug details to stderr',
},
],
configFiles: [
{ path: '~/.elasticrc.yml', required: false, description: 'Primary config file (recommended)' },
Expand Down
7 changes: 7 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ if (hasGlobalFlags) {
.option('--output-template <string>', 'Mustache-like template for custom text output (e.g. "{{id}}: {{name}}")')
}
program.option('--json', 'output as JSON')
program.option('--debug', 'print HTTP request and response details to stderr')
program.hook('preAction', async (thisCommand) => {
if ((thisCommand.opts() as { debug?: boolean }).debug === true) {
const { setHttpDebugEnabled } = await import('./lib/debug-http.js')
setHttpDebugEnabled(true)
}
})

// preAction hook (skipped for --help paths since the hook never fires)
if (!wantsHelp) {
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
8 changes: 8 additions & 0 deletions 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 { logHttpDebug } from './debug-http.ts'
import { isLoopbackUrl } from './is-loopback-host.ts'
import { clientHeaders } from './meta.ts'

Expand Down Expand Up @@ -70,6 +71,13 @@ export class CloudClient {
}

const response = await this._fetch(url, init)
await logHttpDebug({
method: params.method,
url,
headers,
...(typeof init.body === 'string' && { body: init.body }),
response,
})

if (!response.ok) {
const text = await response.text()
Expand Down
74 changes: 74 additions & 0 deletions src/lib/debug-http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and contributors
* SPDX-License-Identifier: Apache-2.0
*/

interface DebugHttpEntry {
method: string
url: string
headers: Record<string, string>
body?: string
response: Response
}

const REDACTED_HEADERS = new Set(['authorization', 'x-api-key'])
let cliDebugEnabled = false

/**
* Sets HTTP debug logging from the parsed root CLI option.
*/
export function setHttpDebugEnabled (value: boolean): void {
cliDebugEnabled = value
}

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

function sanitizeHeaders (headers: Record<string, string>): Record<string, string> {
const sanitized: Record<string, string> = {}
for (const [key, value] of Object.entries(headers)) {
sanitized[key] = REDACTED_HEADERS.has(key.toLowerCase()) ? '(redacted)' : value
}
return sanitized
}

/**
* Writes HTTP request and response debug output to stderr.
*
* The response is cloned before reading its body so callers can still consume
* the original response normally.
*/
export async function logHttpDebug (entry: DebugHttpEntry): Promise<void> {
if (!isHttpDebugEnabled()) return

const lines = [
`Request: ${entry.method} ${entry.url}`,
'Request headers:',
]

for (const [key, value] of Object.entries(sanitizeHeaders(entry.headers))) {
lines.push(`${key}: ${value}`)
}

if (entry.body !== undefined) {
lines.push('Request body:', entry.body)
}

lines.push(`Response: ${entry.response.status}`)

try {
const responseBody = await entry.response.clone().text()
if (responseBody.length > 0) {
lines.push('Response body:', responseBody)
}
} catch {
lines.push('Response body: <unavailable>')
}

process.stderr.write(`${lines.join('\n')}\n`)
}
5 changes: 4 additions & 1 deletion 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 { logHttpDebug } from './debug-http.ts'
import { clientHeaders } from './meta.ts'

export interface EsRequestParams {
Expand Down Expand Up @@ -99,10 +100,10 @@ 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,
Expand All @@ -113,6 +114,8 @@ export class EsClient {
throw new EsConnectionError(err instanceof Error ? err.message : String(err))
}

await logHttpDebug({ method, url, headers, ...(fetchBody !== undefined && { body: fetchBody }), response })

if (isHead) {
if (response.ok) return true as T
if (response.status === 404) return false as T
Expand Down
8 changes: 8 additions & 0 deletions 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 { logHttpDebug } from './debug-http.ts'
import { isLoopbackUrl } from './is-loopback-host.ts'
import { clientHeaders } from './meta.ts'

Expand Down Expand Up @@ -99,6 +100,13 @@ export class KibanaClient {
}

const response = await this._fetch(url, init)
await logHttpDebug({
method,
url,
headers,
...(typeof init.body === 'string' && { body: init.body }),
response,
})

if (!response.ok) {
const text = await response.text()
Expand Down
15 changes: 15 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 Expand Up @@ -374,6 +388,7 @@ describe('elastic CLI -- --help --json', () => {
const { code, stdout } = await runCli(['--help'], { cwd: dir, env: { HOME: dir } })
assert.equal(code, 0, `expected exit code 0, got ${code}`)
assert.match(stdout, /^Usage: elastic/m, 'expected text help format')
assert.match(stdout, /--debug/, 'expected --debug in root help')
assert.throws(() => JSON.parse(stdout), 'text help should not be valid JSON')
} finally {
await rm(dir, { recursive: true })
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
35 changes: 35 additions & 0 deletions test/lib/cloud-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@ import { setResolvedConfig } from '../../src/config/store.ts'
import type { ResolvedConfig } from '../../src/config/types.ts'
import { clientHeaders } from '../../src/lib/meta.ts'

async function captureDebugOutput (fn: () => Promise<unknown>): Promise<string> {
const chunks: string[] = []
const origWrite = process.stderr.write
const previousDebug = process.env['ELASTIC_DEBUG']
process.env['ELASTIC_DEBUG'] = '1'
process.stderr.write = ((chunk: string) => { chunks.push(chunk); return true }) as typeof process.stderr.write
try {
await fn()
} finally {
process.stderr.write = origWrite
if (previousDebug === undefined) delete process.env['ELASTIC_DEBUG']
else process.env['ELASTIC_DEBUG'] = previousDebug
}
return chunks.join('')
}

afterEach(() => {
_testResetCloudClient()
setResolvedConfig(undefined as unknown as ResolvedConfig)
Expand Down Expand Up @@ -162,6 +178,25 @@ describe('getCloudClient', () => {
assert.deepEqual(result, {})
})

it('logs sanitized debug output when ELASTIC_DEBUG=1', async () => {
setResolvedConfig({ context: { cloud: { url: 'https://api.elastic-cloud.com', auth: { api_key: 'secret-key' } } } })
const client = getCloudClient()
client._testSetFetch((() =>
Promise.resolve(new Response('{"id":"deployment-1"}', { status: 200 }))
) as typeof fetch)

const stderr = await captureDebugOutput(() =>
client.request({ method: 'POST', path: '/api/v1/deployments', body: { name: 'test' } })
)

assert.match(stderr, /POST https:\/\/api\.elastic-cloud\.com\/api\/v1\/deployments/)
assert.match(stderr, /Authorization: \(redacted\)/)
assert.match(stderr, /\{"name":"test"\}/)
assert.match(stderr, /Response: 200/)
assert.match(stderr, /\{"id":"deployment-1"\}/)
assert.doesNotMatch(stderr, /secret-key/)
})

it('warns on plaintext HTTP for non-localhost URLs (#107)', () => {
const chunks: string[] = []
const origWrite = process.stderr.write
Expand Down
71 changes: 71 additions & 0 deletions test/lib/debug-http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { isHttpDebugEnabled, logHttpDebug, setHttpDebugEnabled } from '../../src/lib/debug-http.ts'

function captureStderr (fn: () => Promise<unknown> | unknown): Promise<string> {
const chunks: string[] = []
const origWrite = process.stderr.write
process.stderr.write = ((chunk: string) => { chunks.push(chunk); return true }) as typeof process.stderr.write
return Promise.resolve()
.then(fn)
.finally(() => {
process.stderr.write = origWrite
})
.then(() => chunks.join(''))
}

describe('HTTP debug logging', () => {
it('is enabled by ELASTIC_DEBUG=1', () => {
const previous = process.env['ELASTIC_DEBUG']
try {
process.env['ELASTIC_DEBUG'] = '1'
assert.equal(isHttpDebugEnabled(), true)
} finally {
if (previous === undefined) delete process.env['ELASTIC_DEBUG']
else process.env['ELASTIC_DEBUG'] = previous
}
})

it('is enabled by the root --debug option', () => {
try {
setHttpDebugEnabled(true)
assert.equal(isHttpDebugEnabled(), true)
} finally {
setHttpDebugEnabled(false)
}
})

it('redacts credentials and logs request and response details to stderr', async () => {
const stderr = await captureStderr(async () => {
setHttpDebugEnabled(true)
await logHttpDebug({
method: 'POST',
url: 'https://example.com/_search',
headers: {
Authorization: 'ApiKey secret',
'X-Api-Key': 'another-secret',
Accept: 'application/json',
},
body: '{"query":{"match_all":{}}}',
response: new Response('{"ok":true}', { status: 201 }),
})
setHttpDebugEnabled(false)
})

assert.match(stderr, /POST https:\/\/example\.com\/_search/)
assert.match(stderr, /Authorization: \(redacted\)/)
assert.match(stderr, /X-Api-Key: \(redacted\)/)
assert.match(stderr, /Accept: application\/json/)
assert.match(stderr, /\{"query":\{"match_all":\{\}\}\}/)
assert.match(stderr, /Response: 201/)
assert.match(stderr, /\{"ok":true\}/)
assert.doesNotMatch(stderr, /secret/)
assert.doesNotMatch(stderr, /another-secret/)
})

})
Loading