diff --git a/.mega-linter.yml b/.mega-linter.yml index 2e2e9b1a..9cac4946 100644 --- a/.mega-linter.yml +++ b/.mega-linter.yml @@ -1,5 +1,5 @@ ENABLE_LINTERS: - - TYPESCRIPT_ESLINT + - TYPESCRIPT_ES - YAML_YAMLLINT - ACTION_ACTIONLINT - DOCKERFILE_HADOLINT @@ -15,19 +15,25 @@ SARIF_REPORTER: false FLAVOR_SUGGESTIONS: false REPORTERS_MARKDOWN_TYPE: simple -# ESLint 10 compat: install project deps, use local eslint binary +# Install project deps (eslint, typescript-eslint) so eslint.config.js resolves. +# NODE_ENV=production in the container skips devDependencies by default. PRE_COMMANDS: - - command: "npm ci --ignore-scripts" + - command: "NODE_ENV=development npm ci --ignore-scripts" cwd: "workspace" -TYPESCRIPT_ESLINT_CLI_EXECUTABLE: "./node_modules/.bin/eslint" -TYPESCRIPT_ESLINT_CONFIG_FILE: eslint.config.js +# Use project's ESLint 10 (flat config, --no-eslintrc removed in v9+) +TYPESCRIPT_ES_CLI_EXECUTABLE: + - "node" + - "node_modules/.bin/eslint" +TYPESCRIPT_ES_CONFIG_FILE: eslint.config.js +TYPESCRIPT_ES_COMMAND_REMOVE_ARGUMENTS: + - "--no-eslintrc" # Global exclusions FILTER_REGEX_EXCLUDE: "(node_modules/|dist/|build/|\\.git/)" # Skip generated code for ESLint (39k lines of schemas + 2k lines of cloud APIs) -TYPESCRIPT_ESLINT_FILTER_REGEX_EXCLUDE: "(src/es/apis/schemas/|src/cloud/apis/)" +TYPESCRIPT_ES_FILTER_REGEX_EXCLUDE: "(src/es/apis/schemas/|src/cloud/apis/)" # yamllint: skip test fixtures (custom multi-doc YAML DSL) YAML_YAMLLINT_CONFIG_FILE: .yamllint.yml diff --git a/README.md b/README.md index 9831251c..fb6390fe 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,41 @@ auth: To store a value: `security add-generic-password -s elastic-cli -a api-key -w` +#### `secret_service` - freedesktop Secret Service (Linux only) + +Reads a secret from GNOME Keyring or KWallet via `secret-tool`. Uses the same +`service/account` format as `keychain`. + +```yaml +auth: + api_key: $(secret_service:elastic-cli/api-key) +``` + +To store a value: `secret-tool store --label='Elastic API Key' service elastic-cli account api-key` + +#### `pass` - standard Unix password manager (cross-platform) + +Reads the first line from `pass show`. Works on Linux, macOS, and Windows (WSL). + +```yaml +auth: + api_key: $(pass:elastic/api-key) +``` + +To store a value: `pass insert elastic/api-key` + +#### `credential_manager` - Windows Credential Manager (Windows only) + +Reads a credential from Windows Credential Manager using the `service/account` +format. Requires the `CredentialManager` PowerShell module. + +```yaml +auth: + api_key: $(credential_manager:elastic-cli/api-key) +``` + +To store a value: `New-StoredCredential -Target elastic-cli/api-key -UserName _ -Password ` + Expressions can appear in any string field, including URLs: ```yaml diff --git a/src/config/resolvers.ts b/src/config/resolvers.ts index 1e5069f6..3d15c93a 100644 --- a/src/config/resolvers.ts +++ b/src/config/resolvers.ts @@ -101,6 +101,11 @@ function shellEscape (value: string): string { return "'" + value.replace(/'/g, "'\\''") + "'" } +function psEncodedCommand (expression: string): string { + const utf16le = Buffer.from(expression, 'utf16le') + return `powershell -NoProfile -NonInteractive -EncodedCommand ${utf16le.toString('base64')}` +} + const MAX_FILE_SIZE = 64 * 1024 // 64 KB function fileResolver (params: string): string { @@ -137,6 +142,26 @@ function cmdResolver (params: string): string { } } +const PRINTABLE_ASCII_RE = /^[\x20-\x7e]+$/ + +function parseServiceAccount (params: string, resolverName: string): { service: string; account: string } { + if (!PRINTABLE_ASCII_RE.test(params)) { + throw new Error( + `Invalid ${resolverName} parameter "${params}": contains non-printable characters` + ) + } + + const slashIndex = params.indexOf('/') + if (slashIndex === -1 || slashIndex === 0 || slashIndex === params.length - 1) { + throw new Error( + `Invalid ${resolverName} parameter "${params}": expected format "service/account" ` + + `(e.g., "elastic-cli/api-key")` + ) + } + + return { service: params.slice(0, slashIndex), account: params.slice(slashIndex + 1) } +} + function keychainResolver (params: string): string { if (_platform !== 'darwin') { throw new Error( @@ -144,35 +169,109 @@ function keychainResolver (params: string): string { ) } - if (!/^[\x20-\x7e]+$/.test(params)) { + const { service, account } = parseServiceAccount(params, 'keychain') + + 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( - `Invalid keychain parameter "${params}": contains non-printable characters` + `Keychain lookup failed for service="${service}", account="${account}": ${message}`, + { cause: err } ) } +} - const slashIndex = params.indexOf('/') - if (slashIndex === -1 || slashIndex === 0 || slashIndex === params.length - 1) { +function secretServiceResolver (params: string): string { + if (_platform !== 'linux') { throw new Error( - `Invalid keychain parameter "${params}": expected format "service/account" ` + - `(e.g., "elastic-cli/api-key")` + `The secret_service resolver is only supported on Linux (current platform: ${_platform})` ) } - const service = params.slice(0, slashIndex) - const account = params.slice(slashIndex + 1) + const { service, account } = parseServiceAccount(params, 'secret_service') try { return _execSync( - `security find-generic-password -s ${shellEscape(service)} -a ${shellEscape(account)} -w`, + `secret-tool lookup service ${shellEscape(service)} account ${shellEscape(account)}`, 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}`, + `Secret Service lookup failed for service="${service}", account="${account}": ${message}`, + { cause: err } + ) + } +} + +function passResolver (params: string): string { + const path = params.trim() + if (path === '') { + throw new Error('Invalid pass parameter: path must not be empty') + } + if (!PRINTABLE_ASCII_RE.test(path)) { + throw new Error( + `Invalid pass parameter "${path}": contains non-printable characters` + ) + } + + let output: string + try { + output = _execSync( + `pass show ${shellEscape(path)}`, + execOpts(5_000) + ).trimEnd() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new Error( + `pass lookup failed for "${path}": ${message}`, { cause: err } ) } + + const nl = output.indexOf('\n') + const firstLine = nl === -1 ? output : output.slice(0, nl) + if (firstLine === '') { + throw new Error(`pass returned empty output for "${path}"`) + } + return firstLine +} + +function credentialManagerResolver (params: string): string { + if (_platform !== 'win32') { + throw new Error( + `The credential_manager resolver is only supported on Windows (current platform: ${_platform})` + ) + } + + const { service, account } = parseServiceAccount(params, 'credential_manager') + const target = `${service}/${account}` + const expression = `(Get-StoredCredential -Target '${target.replace(/'/g, "''")}').GetNetworkCredential().Password` + + let result: string + try { + result = _execSync( + psEncodedCommand(expression), + execOpts(10_000) + ).trimEnd() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new Error( + `Credential Manager lookup failed for service="${service}", account="${account}": ${message}`, + { cause: err } + ) + } + + if (result === '') { + throw new Error( + `Credential Manager returned empty password for service="${service}", account="${account}"` + ) + } + return result } // --------------------------------------------------------------------------- @@ -184,6 +283,9 @@ function registerBuiltins (): void { registerResolver('env', envResolver) registerResolver('cmd', cmdResolver) registerResolver('keychain', keychainResolver) + registerResolver('secret_service', secretServiceResolver) + registerResolver('pass', passResolver) + registerResolver('credential_manager', credentialManagerResolver) } registerBuiltins() diff --git a/test/config/resolvers.test.ts b/test/config/resolvers.test.ts index c82799c6..0a2d7087 100644 --- a/test/config/resolvers.test.ts +++ b/test/config/resolvers.test.ts @@ -461,6 +461,485 @@ describe('keychain resolver', () => { }) }) +// --------------------------------------------------------------------------- +// secret_service resolver +// --------------------------------------------------------------------------- + +describe('secret_service resolver', () => { + it('resolves a secret on Linux', async () => { + const restorePlatform = _testSetPlatform('linux') + const restoreExec = _testSetExecSync(((cmd: string) => { + assert.match(cmd, /secret-tool lookup/) + assert.match(cmd, /service 'elastic-cli'/) + assert.match(cmd, /account 'my-api-key'/) + return 'secret-value\n' + }) as unknown as typeof import('node:child_process').execSync) + try { + const result = await resolveExpressions('$(secret_service:elastic-cli/my-api-key)') + assert.equal(result, 'secret-value') + } finally { + restoreExec() + restorePlatform() + } + }) + + it('throws on macOS', async () => { + const restorePlatform = _testSetPlatform('darwin') + try { + await assert.rejects( + () => resolveExpressions('$(secret_service:svc/acct)'), + (err: Error) => { + assert.match(err.message, /only supported on Linux/) + assert.match(err.message, /darwin/) + return true + } + ) + } finally { + restorePlatform() + } + }) + + it('throws on Windows', async () => { + const restorePlatform = _testSetPlatform('win32') + try { + await assert.rejects( + () => resolveExpressions('$(secret_service:svc/acct)'), + (err: Error) => { + assert.match(err.message, /only supported on Linux/) + assert.match(err.message, /win32/) + return true + } + ) + } finally { + restorePlatform() + } + }) + + it('throws for missing slash in parameter', async () => { + const restorePlatform = _testSetPlatform('linux') + try { + await assert.rejects( + () => resolveExpressions('$(secret_service:no-slash)'), + (err: Error) => { + assert.match(err.message, /expected format "service\/account"/) + return true + } + ) + } finally { + restorePlatform() + } + }) + + it('throws for leading slash', async () => { + const restorePlatform = _testSetPlatform('linux') + try { + await assert.rejects( + () => resolveExpressions('$(secret_service:/account)'), + (err: Error) => { + assert.match(err.message, /expected format "service\/account"/) + return true + } + ) + } finally { + restorePlatform() + } + }) + + it('throws for trailing slash', async () => { + const restorePlatform = _testSetPlatform('linux') + try { + await assert.rejects( + () => resolveExpressions('$(secret_service:service/)'), + (err: Error) => { + assert.match(err.message, /expected format "service\/account"/) + return true + } + ) + } finally { + restorePlatform() + } + }) + + it('throws for non-printable characters', async () => { + const restorePlatform = _testSetPlatform('linux') + try { + await assert.rejects( + () => resolveExpressions('$(secret_service:svc/acct\x00)'), + (err: Error) => { + assert.match(err.message, /non-printable characters/) + return true + } + ) + } finally { + restorePlatform() + } + }) + + it('splits on first slash only (account can contain slashes)', async () => { + const restorePlatform = _testSetPlatform('linux') + const restoreExec = _testSetExecSync(((cmd: string) => { + assert.match(cmd, /service 'my-service'/) + assert.match(cmd, /account 'path\/to\/key'/) + return 'value\n' + }) as unknown as typeof import('node:child_process').execSync) + try { + const result = await resolveExpressions('$(secret_service:my-service/path/to/key)') + assert.equal(result, 'value') + } finally { + restoreExec() + restorePlatform() + } + }) + + it('shell-escapes special characters', async () => { + const restorePlatform = _testSetPlatform('linux') + let capturedCmd = '' + const restoreExec = _testSetExecSync(((cmd: string) => { + capturedCmd = cmd + return 'val\n' + }) as unknown as typeof import('node:child_process').execSync) + try { + await resolveExpressions("$(secret_service: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 on failure', async () => { + const restorePlatform = _testSetPlatform('linux') + const restoreExec = _testSetExecSync((() => { + throw new Error('secret-tool: not found') + }) as unknown as typeof import('node:child_process').execSync) + try { + await assert.rejects( + () => resolveExpressions('$(secret_service: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() + } + }) +}) + +// --------------------------------------------------------------------------- +// pass resolver +// --------------------------------------------------------------------------- + +describe('pass resolver', () => { + it('resolves the first line of pass output', async () => { + const restoreExec = _testSetExecSync((() => { + return 'my-password\nUsername: user\nURL: https://example.com\n' + }) as unknown as typeof import('node:child_process').execSync) + try { + const result = await resolveExpressions('$(pass:elastic/api-key)') + assert.equal(result, 'my-password') + } finally { + restoreExec() + } + }) + + it('resolves single-line output', async () => { + const restoreExec = _testSetExecSync((() => { + return 'simple-password\n' + }) as unknown as typeof import('node:child_process').execSync) + try { + const result = await resolveExpressions('$(pass:elastic/api-key)') + assert.equal(result, 'simple-password') + } finally { + restoreExec() + } + }) + + it('throws for empty parameter', async () => { + await assert.rejects( + () => resolveExpressions('$(pass: )'), + (err: Error) => { + assert.match(err.message, /path must not be empty/) + return true + } + ) + }) + + it('throws for non-printable characters', async () => { + await assert.rejects( + () => resolveExpressions('$(pass:elastic/key\x00)'), + (err: Error) => { + assert.match(err.message, /non-printable characters/) + return true + } + ) + }) + + it('shell-escapes the path', async () => { + let capturedCmd = '' + const restoreExec = _testSetExecSync(((cmd: string) => { + capturedCmd = cmd + return 'val\n' + }) as unknown as typeof import('node:child_process').execSync) + try { + await resolveExpressions("$(pass:it's/a-key)") + assert.ok(capturedCmd.includes("'it'\\''s/a-key'"), `expected shell-escaped path in: ${capturedCmd}`) + } finally { + restoreExec() + } + }) + + it('works on macOS', async () => { + const restorePlatform = _testSetPlatform('darwin') + const restoreExec = _testSetExecSync((() => 'val\n') as unknown as typeof import('node:child_process').execSync) + try { + const result = await resolveExpressions('$(pass:key)') + assert.equal(result, 'val') + } finally { + restoreExec() + restorePlatform() + } + }) + + it('works on Linux', async () => { + const restorePlatform = _testSetPlatform('linux') + const restoreExec = _testSetExecSync((() => 'val\n') as unknown as typeof import('node:child_process').execSync) + try { + const result = await resolveExpressions('$(pass:key)') + assert.equal(result, 'val') + } finally { + restoreExec() + restorePlatform() + } + }) + + it('throws when pass command fails', async () => { + const restoreExec = _testSetExecSync((() => { + throw new Error('pass: key not found') + }) as unknown as typeof import('node:child_process').execSync) + try { + await assert.rejects( + () => resolveExpressions('$(pass:elastic/missing)'), + (err: Error) => { + assert.match(err.message, /pass lookup failed/) + assert.match(err.message, /elastic\/missing/) + return true + } + ) + } finally { + restoreExec() + } + }) + + it('throws when pass returns empty output', async () => { + const restoreExec = _testSetExecSync((() => '\n') as unknown as typeof import('node:child_process').execSync) + try { + await assert.rejects( + () => resolveExpressions('$(pass:elastic/empty)'), + (err: Error) => { + assert.match(err.message, /empty output/) + return true + } + ) + } finally { + restoreExec() + } + }) +}) + +// --------------------------------------------------------------------------- +// credential_manager resolver +// --------------------------------------------------------------------------- + +describe('credential_manager resolver', () => { + it('resolves a credential on Windows via EncodedCommand', async () => { + const restorePlatform = _testSetPlatform('win32') + const restoreExec = _testSetExecSync(((cmd: string) => { + assert.match(cmd, /powershell/) + assert.match(cmd, /-EncodedCommand/) + const b64 = cmd.split('-EncodedCommand ')[1]! + const decoded = Buffer.from(b64, 'base64').toString('utf16le') + assert.match(decoded, /Get-StoredCredential/) + assert.match(decoded, /elastic-cli\/my-api-key/) + return 'credential-secret\n' + }) as unknown as typeof import('node:child_process').execSync) + try { + const result = await resolveExpressions('$(credential_manager:elastic-cli/my-api-key)') + assert.equal(result, 'credential-secret') + } finally { + restoreExec() + restorePlatform() + } + }) + + it('throws on macOS', async () => { + const restorePlatform = _testSetPlatform('darwin') + try { + await assert.rejects( + () => resolveExpressions('$(credential_manager:svc/acct)'), + (err: Error) => { + assert.match(err.message, /only supported on Windows/) + assert.match(err.message, /darwin/) + return true + } + ) + } finally { + restorePlatform() + } + }) + + it('throws on Linux', async () => { + const restorePlatform = _testSetPlatform('linux') + try { + await assert.rejects( + () => resolveExpressions('$(credential_manager:svc/acct)'), + (err: Error) => { + assert.match(err.message, /only supported on Windows/) + assert.match(err.message, /linux/) + return true + } + ) + } finally { + restorePlatform() + } + }) + + it('throws for missing slash in parameter', async () => { + const restorePlatform = _testSetPlatform('win32') + try { + await assert.rejects( + () => resolveExpressions('$(credential_manager:no-slash)'), + (err: Error) => { + assert.match(err.message, /expected format "service\/account"/) + return true + } + ) + } finally { + restorePlatform() + } + }) + + it('throws for leading slash', async () => { + const restorePlatform = _testSetPlatform('win32') + try { + await assert.rejects( + () => resolveExpressions('$(credential_manager:/account)'), + (err: Error) => { + assert.match(err.message, /expected format "service\/account"/) + return true + } + ) + } finally { + restorePlatform() + } + }) + + it('throws for trailing slash', async () => { + const restorePlatform = _testSetPlatform('win32') + try { + await assert.rejects( + () => resolveExpressions('$(credential_manager:service/)'), + (err: Error) => { + assert.match(err.message, /expected format "service\/account"/) + return true + } + ) + } finally { + restorePlatform() + } + }) + + it('throws for non-printable characters', async () => { + const restorePlatform = _testSetPlatform('win32') + try { + await assert.rejects( + () => resolveExpressions('$(credential_manager:svc/acct\x00)'), + (err: Error) => { + assert.match(err.message, /non-printable characters/) + return true + } + ) + } finally { + restorePlatform() + } + }) + + it('splits on first slash only (account can contain slashes)', async () => { + const restorePlatform = _testSetPlatform('win32') + const restoreExec = _testSetExecSync(((cmd: string) => { + const b64 = cmd.split('-EncodedCommand ')[1]! + const decoded = Buffer.from(b64, 'base64').toString('utf16le') + assert.match(decoded, /my-service\/path\/to\/key/) + return 'value\n' + }) as unknown as typeof import('node:child_process').execSync) + try { + const result = await resolveExpressions('$(credential_manager:my-service/path/to/key)') + assert.equal(result, 'value') + } finally { + restoreExec() + restorePlatform() + } + }) + + it('uses PowerShell escaping (doubled single quotes) in encoded command', async () => { + const restorePlatform = _testSetPlatform('win32') + let capturedCmd = '' + const restoreExec = _testSetExecSync(((cmd: string) => { + capturedCmd = cmd + return 'val\n' + }) as unknown as typeof import('node:child_process').execSync) + try { + await resolveExpressions("$(credential_manager:it's-a-service/acct)") + const b64 = capturedCmd.split('-EncodedCommand ')[1]! + const decoded = Buffer.from(b64, 'base64').toString('utf16le') + assert.ok(decoded.includes("'it''s-a-service/acct'"), `expected PS-escaped target in decoded command: ${decoded}`) + } finally { + restoreExec() + restorePlatform() + } + }) + + it('throws when credential manager returns empty password', async () => { + const restorePlatform = _testSetPlatform('win32') + const restoreExec = _testSetExecSync((() => '\n') as unknown as typeof import('node:child_process').execSync) + try { + await assert.rejects( + () => resolveExpressions('$(credential_manager:my-svc/my-acct)'), + (err: Error) => { + assert.match(err.message, /empty password/) + assert.match(err.message, /service="my-svc"/) + return true + } + ) + } finally { + restoreExec() + restorePlatform() + } + }) + + it('includes service and account in error on failure', async () => { + const restorePlatform = _testSetPlatform('win32') + const restoreExec = _testSetExecSync((() => { + throw new Error('Get-StoredCredential is not recognized') + }) as unknown as typeof import('node:child_process').execSync) + try { + await assert.rejects( + () => resolveExpressions('$(credential_manager: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 // ---------------------------------------------------------------------------