From ea49acd6cd7002479133a088362ab4823ceab86a Mon Sep 17 00:00:00 2001 From: Matt Devy Date: Tue, 14 Apr 2026 12:46:32 +0100 Subject: [PATCH 1/5] feat: add external credential resolvers for config values Adds $(resolver:params) expression syntax for config file values, allowing secrets to be fetched from external sources instead of stored in plaintext. Three built-in resolvers: - env: read from environment variables - cmd: execute a shell command and use stdout - keychain: read from macOS Keychain (macOS only) Closes #128 --- README.md | 74 +++-- src/config/loader.ts | 29 +- src/config/resolvers.ts | 189 +++++++++++++ test/config/resolvers.test.ts | 500 ++++++++++++++++++++++++++++++++++ 4 files changed, 764 insertions(+), 28 deletions(-) create mode 100644 src/config/resolvers.ts create mode 100644 test/config/resolvers.test.ts diff --git a/README.md b/README.md index 03c518ae..9bc642dc 100644 --- a/README.md +++ b/README.md @@ -23,25 +23,19 @@ elastic --help ## Configuration -The CLI searches for a config file starting from the current working directory and -walks up toward your home directory, stopping at the first file found. As a final -fallback it also checks the platform-specific user config directory -(`~/.config/elastic/` on Linux/macOS, `%APPDATA%\elastic\` on Windows). - -The following file names are recognised in each directory (checked in this order): - -1. `package.json` - `elastic` key -2. `.elasticrc` -3. `.elasticrc.json` -4. `.elasticrc.yaml` -5. `.elasticrc.yml` -6. `.elasticrc.js` / `.elasticrc.ts` / `.elasticrc.cjs` / `.elasticrc.mjs` -7. `.config/elasticrc` (and `.json` / `.yaml` / `.yml` / `.js` / `.ts` / `.cjs` / `.mjs` variants) -8. `elastic.config.js` / `.ts` / `.cjs` / `.mjs` - -**Only the first matching file is used - configs are not merged.** Place your -personal config at `~/.elasticrc.yml` (recommended) so it applies everywhere, or -put one in a project root to override it for that project. +The CLI looks for a config file in your home directory. The following file names +are recognised (checked in this order): + +1. `.elasticrc` +2. `.elasticrc.json` +3. `.elasticrc.yaml` +4. `.elasticrc.yml` + +You can also point to a config file explicitly with `--config-file ` or +the `ELASTIC_CLI_CONFIG_FILE` environment variable. Precedence: +`--config-file` > `ELASTIC_CLI_CONFIG_FILE` > home directory discovery. + +JavaScript and TypeScript config files are not supported for security reasons. ```yaml current_context: local @@ -69,6 +63,48 @@ Override `current_context` for a single command with `--use-context `. Each context can have any combination of service blocks (`elasticsearch`, `kibana`, `cloud`). Authentication can also use `username` + `password` instead of `api_key`. +### External credentials + +Instead of storing secrets in plaintext, any string value in the config file can +use `$(resolver:params)` expressions to fetch values from external sources at +runtime. + +#### `env` - environment variables + +```yaml +auth: + api_key: $(env:ELASTIC_API_KEY) +``` + +#### `cmd` - shell command output + +The command is executed and its stdout (trimmed) is used as the value. + +```yaml +auth: + api_key: $(cmd:pass show elastic/api-key) +``` + +#### `keychain` - macOS Keychain (macOS only) + +Reads a password from the macOS Keychain using the `service/account` format. + +```yaml +auth: + api_key: $(keychain:elastic-cli/api-key) +``` + +To store a value: `security add-generic-password -s elastic-cli -a api-key -w` + +Expressions can appear in any string field, including URLs: + +```yaml +elasticsearch: + url: https://$(env:ES_HOST):9200 + auth: + api_key: $(keychain:elastic-cli/api-key) +``` + ## Global options | Option | Description | diff --git a/src/config/loader.ts b/src/config/loader.ts index 4099e00f..ed6650fa 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -8,9 +8,10 @@ * * Pipeline: * 1. Create cosmiconfig explorer with ID 'elastic' for discovery - * 2. Load and validate config file with Zod schemas - * 3. Resolve the active context (default or --use-context override) - * 4. Return typed ResolvedConfig to command handlers + * 2. Resolve expressions in config values (e.g. $(env:VAR), $(cmd:...), $(keychain:...)) + * 3. Validate resolved config with Zod schemas + * 4. Resolve the active context (default or --use-context override) + * 5. Return typed ResolvedConfig to command handlers * * Supports: * - cosmiconfig discovery (searches standard locations) @@ -23,6 +24,7 @@ import { z } from 'zod' import { cosmiconfig } from 'cosmiconfig' import { ConfigFileSchema } from './schema.ts' +import { resolveExpressions } from './resolvers.ts' import type { ConfigFile, ResolvedConfig, ResolvedContext } from './types.ts' /** @@ -120,9 +122,10 @@ export type LoadConfigResult = LoadConfigOk | LoadConfigErr * * Steps: * 1. Load raw config via cosmiconfig (discovery or explicit path) - * 2. Validate with `ConfigFileSchema` (Zod) - * 3. Resolve the active context (from `contextName` override or `current_context`) - * 4. Return a typed `ResolvedConfig` + * 2. Resolve expressions in string values (env, cmd, keychain) + * 3. Validate with `ConfigFileSchema` (Zod) + * 4. Resolve the active context (from `contextName` override or `current_context`) + * 5. Return a typed `ResolvedConfig` * * All failure modes return `{ ok: false, error: { message } }` -- never throw. * @@ -153,7 +156,15 @@ export async function loadConfig (options: LoadConfigOptions = {}): Promise string | Promise + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +const registry = new Map() + +export function registerResolver (name: string, fn: ResolverFn): void { + registry.set(name, fn) +} + +export function getResolver (name: string): ResolverFn | undefined { + return registry.get(name) +} + +// --------------------------------------------------------------------------- +// Expression parser +// --------------------------------------------------------------------------- + +const EXPRESSION_RE = /\$\(([a-z_][a-z0-9_]*):([^)]+)\)/ + +export function containsExpression (value: string): boolean { + return value.includes('$(') +} + +export async function resolveString ( + value: string, + context: ResolverContext, + fieldPath: string +): Promise { + if (!containsExpression(value)) return value + + const matches = [...value.matchAll(new RegExp(EXPRESSION_RE.source, 'g'))] + if (matches.length === 0) return value + + let result = value + for (const match of matches) { + const full = match[0] + const name = match[1]! + const params = match[2]! + const resolver = getResolver(name) + if (resolver == null) { + throw new Error( + `Unknown resolver "${name}" in expression "${full}" at ${fieldPath}. ` + + `Available resolvers: ${[...registry.keys()].join(', ')}` + ) + } + const resolved = await resolver(params, context) + result = result.replaceAll(full, resolved) + } + + return result +} + +// --------------------------------------------------------------------------- +// Deep object walk +// --------------------------------------------------------------------------- + +export async function resolveExpressions ( + obj: unknown, + context: ResolverContext = {}, + path: string = '' +): Promise { + if (typeof obj === 'string') { + return resolveString(obj, context, path || '') + } + if (Array.isArray(obj)) { + return Promise.all(obj.map((item, i) => resolveExpressions(item, context, `${path}[${i}]`))) + } + if (obj !== null && typeof obj === 'object') { + const result: Record = {} + for (const [key, value] of Object.entries(obj)) { + const fieldPath = path ? `${path}.${key}` : key + result[key] = await resolveExpressions(value, context, fieldPath) + } + return result + } + return obj +} + +// --------------------------------------------------------------------------- +// Built-in resolvers +// --------------------------------------------------------------------------- + +let _execSync: typeof execSync = execSync +let _platform: string = process.platform + +function execOpts (timeoutMs: number): ExecSyncOptionsWithStringEncoding { + return { encoding: 'utf-8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true } +} + +function shellEscape (value: string): string { + return "'" + value.replace(/'/g, "'\\''") + "'" +} + +function envResolver (params: string): string { + const value = process.env[params] + if (value == null || value === '') { + throw new Error(`Environment variable "${params}" is not set or is empty`) + } + return value +} + +function cmdResolver (params: string): string { + try { + return _execSync(params, execOpts(10_000)).trimEnd() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new Error(`Command failed: ${params}\n${message}`) + } +} + +function keychainResolver (params: string): string { + if (_platform !== 'darwin') { + throw new Error( + `The keychain resolver is only supported on macOS (current platform: ${_platform})` + ) + } + + const slashIndex = params.indexOf('/') + if (slashIndex === -1 || slashIndex === 0 || slashIndex === params.length - 1) { + throw new Error( + `Invalid keychain parameter "${params}": expected format "service/account" ` + + `(e.g., "elastic-cli/api-key")` + ) + } + + const service = params.slice(0, slashIndex) + const account = params.slice(slashIndex + 1) + + try { + return _execSync( + `security find-generic-password -s ${shellEscape(service)} -a ${shellEscape(account)} -w`, + execOpts(5_000) + ).trimEnd() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new Error( + `Keychain lookup failed for service="${service}", account="${account}": ${message}` + ) + } +} + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +function registerBuiltins (): void { + registerResolver('env', envResolver) + registerResolver('cmd', cmdResolver) + registerResolver('keychain', keychainResolver) +} + +registerBuiltins() + +// --------------------------------------------------------------------------- +// Test seams +// --------------------------------------------------------------------------- + +export function _testResetResolvers (): void { + registry.clear() + registerBuiltins() +} + +export function _testSetExecSync (fn: typeof execSync): () => void { + const prev = _execSync + _execSync = fn + return () => { _execSync = prev } +} + +export function _testSetPlatform (p: string): () => void { + const prev = _platform + _platform = p + return () => { _platform = prev } +} diff --git a/test/config/resolvers.test.ts b/test/config/resolvers.test.ts new file mode 100644 index 00000000..38367537 --- /dev/null +++ b/test/config/resolvers.test.ts @@ -0,0 +1,500 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, before, after, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { loadConfig } from '../../src/config/loader.ts' +import { + containsExpression, + resolveString, + resolveExpressions, + registerResolver, + _testResetResolvers, + _testSetExecSync, + _testSetPlatform, +} from '../../src/config/resolvers.ts' + +afterEach(() => { + _testResetResolvers() +}) + +// --------------------------------------------------------------------------- +// containsExpression +// --------------------------------------------------------------------------- + +describe('containsExpression', () => { + it('returns false for plain strings', () => { + assert.equal(containsExpression('hello world'), false) + }) + + it('returns true for strings with $(', () => { + assert.equal(containsExpression('$(env:FOO)'), true) + }) + + it('returns true even if expression is incomplete', () => { + assert.equal(containsExpression('value with $( but no closing'), true) + }) +}) + +// --------------------------------------------------------------------------- +// resolveString +// --------------------------------------------------------------------------- + +describe('resolveString', () => { + it('returns plain strings unchanged', async () => { + const result = await resolveString('hello', {}, 'test') + assert.equal(result, 'hello') + }) + + it('returns strings with $( but no valid expression unchanged', async () => { + const result = await resolveString('value $( not valid', {}, 'test') + assert.equal(result, 'value $( not valid') + }) + + it('resolves a single expression that is the entire value', async () => { + registerResolver('echo', (params) => params) + const result = await resolveString('$(echo:hello)', {}, 'test') + assert.equal(result, 'hello') + }) + + it('resolves an expression embedded in a string', async () => { + registerResolver('echo', (params) => params) + const result = await resolveString('https://$(echo:myhost):9200', {}, 'test') + assert.equal(result, 'https://myhost:9200') + }) + + it('resolves multiple expressions in one string', async () => { + registerResolver('echo', (params) => params) + const result = await resolveString('$(echo:https)://$(echo:myhost)', {}, 'test') + assert.equal(result, 'https://myhost') + }) + + it('throws for an unknown resolver name', async () => { + await assert.rejects( + () => resolveString('$(unknown:foo)', {}, 'my.field'), + (err: Error) => { + assert.match(err.message, /Unknown resolver "unknown"/) + assert.match(err.message, /my\.field/) + return true + } + ) + }) + + it('replaces all occurrences of the same expression', async () => { + registerResolver('echo', (params) => params) + const result = await resolveString('$(echo:val)-$(echo:val)', {}, 'test') + assert.equal(result, 'val-val') + }) + + it('supports async resolvers', async () => { + registerResolver('async_echo', async (params) => params) + const result = await resolveString('$(async_echo:world)', {}, 'test') + assert.equal(result, 'world') + }) +}) + +// --------------------------------------------------------------------------- +// resolveExpressions (deep walk) +// --------------------------------------------------------------------------- + +describe('resolveExpressions', () => { + it('returns non-string primitives unchanged', async () => { + assert.equal(await resolveExpressions(42), 42) + assert.equal(await resolveExpressions(true), true) + assert.equal(await resolveExpressions(null), null) + }) + + it('returns a plain string unchanged', async () => { + assert.equal(await resolveExpressions('hello'), 'hello') + }) + + it('resolves expressions in nested objects', async () => { + registerResolver('echo', (params) => params) + const input = { + level1: { + level2: '$(echo:deep)', + plain: 'no-expression', + }, + } + const result = await resolveExpressions(input) + assert.deepEqual(result, { + level1: { + level2: 'deep', + plain: 'no-expression', + }, + }) + }) + + it('resolves expressions in arrays', async () => { + registerResolver('echo', (params) => params) + const input = ['$(echo:a)', 'plain', '$(echo:b)'] + const result = await resolveExpressions(input) + assert.deepEqual(result, ['a', 'plain', 'b']) + }) + + it('preserves non-string values in mixed objects', async () => { + registerResolver('echo', (params) => params) + const input = { + str: '$(echo:resolved)', + num: 123, + bool: false, + nil: null, + } + const result = await resolveExpressions(input) + assert.deepEqual(result, { + str: 'resolved', + num: 123, + bool: false, + nil: null, + }) + }) + + it('includes field path in error messages for nested values', async () => { + await assert.rejects( + () => resolveExpressions({ + contexts: { + local: { + elasticsearch: { + auth: { api_key: '$(unknown:x)' }, + }, + }, + }, + }), + (err: Error) => { + assert.match(err.message, /contexts\.local\.elasticsearch\.auth\.api_key/) + return true + } + ) + }) +}) + +// --------------------------------------------------------------------------- +// env resolver +// --------------------------------------------------------------------------- + +describe('env resolver', () => { + const TEST_VAR = 'ELASTIC_CLI_TEST_RESOLVER_VAR' + + afterEach(() => { + delete process.env[TEST_VAR] + }) + + it('resolves a set environment variable', async () => { + process.env[TEST_VAR] = 'my-secret' + const result = await resolveExpressions(`$(env:${TEST_VAR})`) + assert.equal(result, 'my-secret') + }) + + it('throws for an unset environment variable', async () => { + delete process.env[TEST_VAR] + await assert.rejects( + () => resolveExpressions(`$(env:${TEST_VAR})`), + (err: Error) => { + assert.match(err.message, /not set or is empty/) + assert.match(err.message, new RegExp(TEST_VAR)) + return true + } + ) + }) + + it('throws for an empty environment variable', async () => { + process.env[TEST_VAR] = '' + await assert.rejects( + () => resolveExpressions(`$(env:${TEST_VAR})`), + (err: Error) => { + assert.match(err.message, /not set or is empty/) + return true + } + ) + }) +}) + +// --------------------------------------------------------------------------- +// cmd resolver +// --------------------------------------------------------------------------- + +describe('cmd resolver', () => { + it('resolves command output with trailing newline trimmed', async () => { + const restore = _testSetExecSync((() => 'secret-value\n') as unknown as typeof import('node:child_process').execSync) + try { + const result = await resolveExpressions('$(cmd:echo secret-value)') + assert.equal(result, 'secret-value') + } finally { + restore() + } + }) + + it('throws when the command fails', async () => { + const restore = _testSetExecSync((() => { + throw new Error('Command not found: badcmd') + }) as unknown as typeof import('node:child_process').execSync) + try { + await assert.rejects( + () => resolveExpressions('$(cmd:badcmd)'), + (err: Error) => { + assert.match(err.message, /Command failed: badcmd/) + return true + } + ) + } finally { + restore() + } + }) + + it('includes the command text in the error message', async () => { + const restore = _testSetExecSync((() => { + throw new Error('exit code 1') + }) as unknown as typeof import('node:child_process').execSync) + try { + await assert.rejects( + () => resolveExpressions('$(cmd:pass show elastic/key)'), + (err: Error) => { + assert.match(err.message, /pass show elastic\/key/) + return true + } + ) + } finally { + restore() + } + }) +}) + +// --------------------------------------------------------------------------- +// keychain resolver +// --------------------------------------------------------------------------- + +describe('keychain resolver', () => { + it('resolves a keychain value on macOS', async () => { + const restorePlatform = _testSetPlatform('darwin') + const restoreExec = _testSetExecSync(((cmd: string) => { + assert.match(cmd, /security find-generic-password/) + assert.match(cmd, /-s 'elastic-cli'/) + assert.match(cmd, /-a 'my-api-key'/) + return 'keychain-secret\n' + }) as unknown as typeof import('node:child_process').execSync) + try { + const result = await resolveExpressions('$(keychain:elastic-cli/my-api-key)') + assert.equal(result, 'keychain-secret') + } finally { + restoreExec() + restorePlatform() + } + }) + + it('throws on non-macOS platforms', async () => { + const restorePlatform = _testSetPlatform('linux') + try { + await assert.rejects( + () => resolveExpressions('$(keychain:svc/acct)'), + (err: Error) => { + assert.match(err.message, /only supported on macOS/) + assert.match(err.message, /linux/) + return true + } + ) + } finally { + restorePlatform() + } + }) + + it('throws for missing slash in parameter', async () => { + const restorePlatform = _testSetPlatform('darwin') + try { + await assert.rejects( + () => resolveExpressions('$(keychain:no-slash)'), + (err: Error) => { + assert.match(err.message, /expected format "service\/account"/) + return true + } + ) + } finally { + restorePlatform() + } + }) + + it('throws for leading slash in parameter', async () => { + const restorePlatform = _testSetPlatform('darwin') + try { + await assert.rejects( + () => resolveExpressions('$(keychain:/account)'), + (err: Error) => { + assert.match(err.message, /expected format "service\/account"/) + return true + } + ) + } finally { + restorePlatform() + } + }) + + it('throws for trailing slash in parameter', async () => { + const restorePlatform = _testSetPlatform('darwin') + try { + await assert.rejects( + () => resolveExpressions('$(keychain:service/)'), + (err: Error) => { + assert.match(err.message, /expected format "service\/account"/) + return true + } + ) + } finally { + restorePlatform() + } + }) + + it('splits on first slash only (account can contain slashes)', async () => { + const restorePlatform = _testSetPlatform('darwin') + const restoreExec = _testSetExecSync(((cmd: string) => { + assert.match(cmd, /-s 'my-service'/) + assert.match(cmd, /-a 'path\/to\/key'/) + return 'value\n' + }) as unknown as typeof import('node:child_process').execSync) + try { + const result = await resolveExpressions('$(keychain:my-service/path/to/key)') + assert.equal(result, 'value') + } finally { + restoreExec() + restorePlatform() + } + }) + + it('shell-escapes special characters in service and account', async () => { + const restorePlatform = _testSetPlatform('darwin') + let capturedCmd = '' + const restoreExec = _testSetExecSync(((cmd: string) => { + capturedCmd = cmd + return 'val\n' + }) as unknown as typeof import('node:child_process').execSync) + try { + await resolveExpressions("$(keychain:it's-a-service/acct)") + assert.ok(capturedCmd.includes("'it'\\''s-a-service'"), `expected shell-escaped service in: ${capturedCmd}`) + } finally { + restoreExec() + restorePlatform() + } + }) + + it('includes service and account in error message on failure', async () => { + const restorePlatform = _testSetPlatform('darwin') + const restoreExec = _testSetExecSync((() => { + throw new Error('The specified item could not be found') + }) as unknown as typeof import('node:child_process').execSync) + try { + await assert.rejects( + () => resolveExpressions('$(keychain:my-svc/my-acct)'), + (err: Error) => { + assert.match(err.message, /service="my-svc"/) + assert.match(err.message, /account="my-acct"/) + return true + } + ) + } finally { + restoreExec() + restorePlatform() + } + }) +}) + +// --------------------------------------------------------------------------- +// Integration: loadConfig with expressions +// --------------------------------------------------------------------------- + +describe('loadConfig with resolver expressions', () => { + const ENV_VAR = 'ELASTIC_CLI_TEST_RESOLVER_INTEGRATION' + let tmpDir: string + + before(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'elastic-cli-resolvers-')) + }) + after(async () => { + delete process.env[ENV_VAR] + await rm(tmpDir, { recursive: true }) + }) + + it('resolves $(env:...) expressions before Zod validation', async () => { + process.env[ENV_VAR] = 'integration-api-key' + const yaml = ` +current_context: local +contexts: + local: + elasticsearch: + url: http://localhost:9200 + auth: + api_key: $(env:${ENV_VAR}) +`.trimStart() + const configPath = join(tmpDir, 'env-resolve.yml') + await writeFile(configPath, yaml) + const result = await loadConfig({ configPath }) + assert.ok(result.ok, `expected ok, got: ${!result.ok ? result.error.message : ''}`) + if (!result.ok) return + assert.equal( + (result.value.context.elasticsearch!.auth as { api_key: string }).api_key, + 'integration-api-key' + ) + }) + + it('returns error when an expression fails to resolve', async () => { + delete process.env[ENV_VAR] + const yaml = ` +current_context: local +contexts: + local: + elasticsearch: + url: http://localhost:9200 + auth: + api_key: $(env:${ENV_VAR}) +`.trimStart() + const configPath = join(tmpDir, 'env-fail.yml') + await writeFile(configPath, yaml) + const result = await loadConfig({ configPath }) + assert.ok(!result.ok, 'expected failure for unset env var') + if (result.ok) return + assert.match(result.error.message, /Failed to resolve config expressions/) + assert.match(result.error.message, new RegExp(ENV_VAR)) + }) + + it('works normally when config has no expressions (regression)', async () => { + const yaml = ` +current_context: local +contexts: + local: + elasticsearch: + url: http://localhost:9200 + auth: + api_key: plain-key +`.trimStart() + const configPath = join(tmpDir, 'no-expressions.yml') + await writeFile(configPath, yaml) + const result = await loadConfig({ configPath }) + assert.ok(result.ok, `expected ok, got: ${!result.ok ? result.error.message : ''}`) + if (!result.ok) return + assert.equal( + (result.value.context.elasticsearch!.auth as { api_key: string }).api_key, + 'plain-key' + ) + }) + + it('resolves expressions in URL fields', async () => { + process.env[ENV_VAR] = 'my-host.example.com' + const yaml = ` +current_context: local +contexts: + local: + elasticsearch: + url: https://$(env:${ENV_VAR}):9200 + auth: + api_key: plain-key +`.trimStart() + const configPath = join(tmpDir, 'url-resolve.yml') + await writeFile(configPath, yaml) + const result = await loadConfig({ configPath }) + assert.ok(result.ok, `expected ok, got: ${!result.ok ? result.error.message : ''}`) + if (!result.ok) return + assert.equal(result.value.context.elasticsearch!.url, 'https://my-host.example.com:9200') + }) +}) From 20798f85ccbe566b00032bf210d70c25e8114c88 Mon Sep 17 00:00:00 2001 From: Matt Devy Date: Tue, 14 Apr 2026 12:53:10 +0100 Subject: [PATCH 2/5] fix: resolve lint errors in resolvers module - Remove empty ResolverContext interface (and context param) - Attach cause to re-thrown errors in cmd and keychain resolvers --- src/config/resolvers.ts | 23 ++++++++--------------- test/config/resolvers.test.ts | 16 ++++++++-------- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/src/config/resolvers.ts b/src/config/resolvers.ts index 7ab599ad..03f3cb09 100644 --- a/src/config/resolvers.ts +++ b/src/config/resolvers.ts @@ -5,13 +5,7 @@ import { execSync, type ExecSyncOptionsWithStringEncoding } from 'node:child_process' -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface ResolverContext {} - -export type ResolverFn = (params: string, context: ResolverContext) => string | Promise +export type ResolverFn = (params: string) => string | Promise // --------------------------------------------------------------------------- // Registry @@ -39,7 +33,6 @@ export function containsExpression (value: string): boolean { export async function resolveString ( value: string, - context: ResolverContext, fieldPath: string ): Promise { if (!containsExpression(value)) return value @@ -59,7 +52,7 @@ export async function resolveString ( `Available resolvers: ${[...registry.keys()].join(', ')}` ) } - const resolved = await resolver(params, context) + const resolved = await resolver(params) result = result.replaceAll(full, resolved) } @@ -72,20 +65,19 @@ export async function resolveString ( export async function resolveExpressions ( obj: unknown, - context: ResolverContext = {}, path: string = '' ): Promise { if (typeof obj === 'string') { - return resolveString(obj, context, path || '') + return resolveString(obj, path || '') } if (Array.isArray(obj)) { - return Promise.all(obj.map((item, i) => resolveExpressions(item, context, `${path}[${i}]`))) + return Promise.all(obj.map((item, i) => resolveExpressions(item, `${path}[${i}]`))) } if (obj !== null && typeof obj === 'object') { const result: Record = {} for (const [key, value] of Object.entries(obj)) { const fieldPath = path ? `${path}.${key}` : key - result[key] = await resolveExpressions(value, context, fieldPath) + result[key] = await resolveExpressions(value, fieldPath) } return result } @@ -120,7 +112,7 @@ function cmdResolver (params: string): string { return _execSync(params, execOpts(10_000)).trimEnd() } catch (err) { const message = err instanceof Error ? err.message : String(err) - throw new Error(`Command failed: ${params}\n${message}`) + throw new Error(`Command failed: ${params}\n${message}`, { cause: err }) } } @@ -150,7 +142,8 @@ function keychainResolver (params: string): string { } catch (err) { const message = err instanceof Error ? err.message : String(err) throw new Error( - `Keychain lookup failed for service="${service}", account="${account}": ${message}` + `Keychain lookup failed for service="${service}", account="${account}": ${message}`, + { cause: err } ) } } diff --git a/test/config/resolvers.test.ts b/test/config/resolvers.test.ts index 38367537..398b5fad 100644 --- a/test/config/resolvers.test.ts +++ b/test/config/resolvers.test.ts @@ -47,36 +47,36 @@ describe('containsExpression', () => { describe('resolveString', () => { it('returns plain strings unchanged', async () => { - const result = await resolveString('hello', {}, 'test') + const result = await resolveString('hello', 'test') assert.equal(result, 'hello') }) it('returns strings with $( but no valid expression unchanged', async () => { - const result = await resolveString('value $( not valid', {}, 'test') + const result = await resolveString('value $( not valid', 'test') assert.equal(result, 'value $( not valid') }) it('resolves a single expression that is the entire value', async () => { registerResolver('echo', (params) => params) - const result = await resolveString('$(echo:hello)', {}, 'test') + const result = await resolveString('$(echo:hello)', 'test') assert.equal(result, 'hello') }) it('resolves an expression embedded in a string', async () => { registerResolver('echo', (params) => params) - const result = await resolveString('https://$(echo:myhost):9200', {}, 'test') + const result = await resolveString('https://$(echo:myhost):9200', 'test') assert.equal(result, 'https://myhost:9200') }) it('resolves multiple expressions in one string', async () => { registerResolver('echo', (params) => params) - const result = await resolveString('$(echo:https)://$(echo:myhost)', {}, 'test') + const result = await resolveString('$(echo:https)://$(echo:myhost)', 'test') assert.equal(result, 'https://myhost') }) it('throws for an unknown resolver name', async () => { await assert.rejects( - () => resolveString('$(unknown:foo)', {}, 'my.field'), + () => resolveString('$(unknown:foo)', 'my.field'), (err: Error) => { assert.match(err.message, /Unknown resolver "unknown"/) assert.match(err.message, /my\.field/) @@ -87,13 +87,13 @@ describe('resolveString', () => { it('replaces all occurrences of the same expression', async () => { registerResolver('echo', (params) => params) - const result = await resolveString('$(echo:val)-$(echo:val)', {}, 'test') + const result = await resolveString('$(echo:val)-$(echo:val)', 'test') assert.equal(result, 'val-val') }) it('supports async resolvers', async () => { registerResolver('async_echo', async (params) => params) - const result = await resolveString('$(async_echo:world)', {}, 'test') + const result = await resolveString('$(async_echo:world)', 'test') assert.equal(result, 'world') }) }) From 2324b02acf392a724490acdcbb2b8a093a501694 Mon Sep 17 00:00:00 2001 From: Matt Devy Date: Tue, 14 Apr 2026 12:59:00 +0100 Subject: [PATCH 3/5] fix: harden resolvers against prototype pollution and control chars - Skip __proto__ and constructor keys in deep object walk - Reject non-printable characters in keychain service/account params --- src/config/resolvers.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/config/resolvers.ts b/src/config/resolvers.ts index 03f3cb09..79b9e564 100644 --- a/src/config/resolvers.ts +++ b/src/config/resolvers.ts @@ -76,6 +76,7 @@ export async function resolveExpressions ( if (obj !== null && typeof obj === 'object') { const result: Record = {} for (const [key, value] of Object.entries(obj)) { + if (key === '__proto__' || key === 'constructor') continue const fieldPath = path ? `${path}.${key}` : key result[key] = await resolveExpressions(value, fieldPath) } @@ -123,6 +124,12 @@ function keychainResolver (params: string): string { ) } + if (!/^[\x20-\x7e]+$/.test(params)) { + throw new Error( + `Invalid keychain parameter "${params}": contains non-printable characters` + ) + } + const slashIndex = params.indexOf('/') if (slashIndex === -1 || slashIndex === 0 || slashIndex === params.length - 1) { throw new Error( From 1727b649401afd95c27dbbbabffef36ee1007f48 Mon Sep 17 00:00:00 2001 From: Matt Devy Date: Tue, 14 Apr 2026 13:12:43 +0100 Subject: [PATCH 4/5] feat: add file resolver for reading secrets from disk Supports $(file:/path/to/secret) syntax, useful for Docker/Kubernetes secrets mounted at /run/secrets/. --- README.md | 10 +++++++++ src/config/resolvers.ts | 11 ++++++++++ test/config/resolvers.test.ts | 38 +++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/README.md b/README.md index 9bc642dc..43315746 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,16 @@ Instead of storing secrets in plaintext, any string value in the config file can use `$(resolver:params)` expressions to fetch values from external sources at runtime. +#### `file` - read from a file + +Reads the contents of a file (trimmed). Useful for Docker/Kubernetes secrets +mounted at `/run/secrets/`. + +```yaml +auth: + api_key: $(file:/run/secrets/elastic_api_key) +``` + #### `env` - environment variables ```yaml diff --git a/src/config/resolvers.ts b/src/config/resolvers.ts index 79b9e564..10175d1e 100644 --- a/src/config/resolvers.ts +++ b/src/config/resolvers.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { readFileSync } from 'node:fs' import { execSync, type ExecSyncOptionsWithStringEncoding } from 'node:child_process' export type ResolverFn = (params: string) => string | Promise @@ -100,6 +101,15 @@ function shellEscape (value: string): string { return "'" + value.replace(/'/g, "'\\''") + "'" } +function fileResolver (params: string): string { + try { + return readFileSync(params, 'utf-8').trimEnd() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new Error(`Failed to read file "${params}": ${message}`, { cause: err }) + } +} + function envResolver (params: string): string { const value = process.env[params] if (value == null || value === '') { @@ -160,6 +170,7 @@ function keychainResolver (params: string): string { // --------------------------------------------------------------------------- function registerBuiltins (): void { + registerResolver('file', fileResolver) registerResolver('env', envResolver) registerResolver('cmd', cmdResolver) registerResolver('keychain', keychainResolver) diff --git a/test/config/resolvers.test.ts b/test/config/resolvers.test.ts index 398b5fad..a7c9daa5 100644 --- a/test/config/resolvers.test.ts +++ b/test/config/resolvers.test.ts @@ -173,6 +173,44 @@ describe('resolveExpressions', () => { }) }) +// --------------------------------------------------------------------------- +// file resolver +// --------------------------------------------------------------------------- + +describe('file resolver', () => { + let tmpDir: string + + before(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'elastic-cli-file-resolver-')) + }) + after(async () => rm(tmpDir, { recursive: true })) + + it('reads file contents with trailing newline trimmed', async () => { + const filePath = join(tmpDir, 'secret.txt') + await writeFile(filePath, 'my-secret-value\n') + const result = await resolveExpressions(`$(file:${filePath})`) + assert.equal(result, 'my-secret-value') + }) + + it('reads file contents without trailing newline', async () => { + const filePath = join(tmpDir, 'secret-no-newline.txt') + await writeFile(filePath, 'exact-value') + const result = await resolveExpressions(`$(file:${filePath})`) + assert.equal(result, 'exact-value') + }) + + it('throws for a nonexistent file', async () => { + await assert.rejects( + () => resolveExpressions(`$(file:${join(tmpDir, 'nonexistent.txt')})`), + (err: Error) => { + assert.match(err.message, /Failed to read file/) + assert.match(err.message, /nonexistent\.txt/) + return true + } + ) + }) +}) + // --------------------------------------------------------------------------- // env resolver // --------------------------------------------------------------------------- From 81bc12bb567d383f018974333a2ce18596a24a7c Mon Sep 17 00:00:00 2001 From: Matt Devy Date: Tue, 14 Apr 2026 13:16:51 +0100 Subject: [PATCH 5/5] fix: harden file resolver with stat check and size cap - Reject non-regular files (directories, device files, sockets) - Reject files larger than 64 KB --- src/config/resolvers.ts | 14 ++++++++++++-- test/config/resolvers.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/config/resolvers.ts b/src/config/resolvers.ts index 10175d1e..1e5069f6 100644 --- a/src/config/resolvers.ts +++ b/src/config/resolvers.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { readFileSync } from 'node:fs' +import { readFileSync, statSync } from 'node:fs' import { execSync, type ExecSyncOptionsWithStringEncoding } from 'node:child_process' export type ResolverFn = (params: string) => string | Promise @@ -101,13 +101,23 @@ function shellEscape (value: string): string { return "'" + value.replace(/'/g, "'\\''") + "'" } +const MAX_FILE_SIZE = 64 * 1024 // 64 KB + function fileResolver (params: string): string { + let stat try { - return readFileSync(params, 'utf-8').trimEnd() + stat = statSync(params) } catch (err) { const message = err instanceof Error ? err.message : String(err) throw new Error(`Failed to read file "${params}": ${message}`, { cause: err }) } + if (!stat.isFile()) { + throw new Error(`Failed to read file "${params}": not a regular file`) + } + if (stat.size > MAX_FILE_SIZE) { + throw new Error(`Failed to read file "${params}": file is ${stat.size} bytes (max ${MAX_FILE_SIZE})`) + } + return readFileSync(params, 'utf-8').trimEnd() } function envResolver (params: string): string { diff --git a/test/config/resolvers.test.ts b/test/config/resolvers.test.ts index a7c9daa5..c82799c6 100644 --- a/test/config/resolvers.test.ts +++ b/test/config/resolvers.test.ts @@ -209,6 +209,29 @@ describe('file resolver', () => { } ) }) + + it('throws for a directory', async () => { + await assert.rejects( + () => resolveExpressions(`$(file:${tmpDir})`), + (err: Error) => { + assert.match(err.message, /not a regular file/) + return true + } + ) + }) + + it('throws for a file exceeding the size limit', async () => { + const filePath = join(tmpDir, 'large.txt') + await writeFile(filePath, 'x'.repeat(65 * 1024)) + await assert.rejects( + () => resolveExpressions(`$(file:${filePath})`), + (err: Error) => { + assert.match(err.message, /bytes/) + assert.match(err.message, /max/) + return true + } + ) + }) }) // ---------------------------------------------------------------------------