Skip to content

Commit 92e6982

Browse files
authored
feat: add secret_service, pass, and credential_manager resolvers (#152)
1 parent 1898bc2 commit 92e6982

4 files changed

Lines changed: 638 additions & 16 deletions

File tree

.mega-linter.yml

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
ENABLE_LINTERS:
2-
- TYPESCRIPT_ESLINT
2+
- TYPESCRIPT_ES
33
- YAML_YAMLLINT
44
- ACTION_ACTIONLINT
55
- DOCKERFILE_HADOLINT
@@ -15,19 +15,25 @@ SARIF_REPORTER: false
1515
FLAVOR_SUGGESTIONS: false
1616
REPORTERS_MARKDOWN_TYPE: simple
1717

18-
# ESLint 10 compat: install project deps, use local eslint binary
18+
# Install project deps (eslint, typescript-eslint) so eslint.config.js resolves.
19+
# NODE_ENV=production in the container skips devDependencies by default.
1920
PRE_COMMANDS:
20-
- command: "npm ci --ignore-scripts"
21+
- command: "NODE_ENV=development npm ci --ignore-scripts"
2122
cwd: "workspace"
2223

23-
TYPESCRIPT_ESLINT_CLI_EXECUTABLE: "./node_modules/.bin/eslint"
24-
TYPESCRIPT_ESLINT_CONFIG_FILE: eslint.config.js
24+
# Use project's ESLint 10 (flat config, --no-eslintrc removed in v9+)
25+
TYPESCRIPT_ES_CLI_EXECUTABLE:
26+
- "node"
27+
- "node_modules/.bin/eslint"
28+
TYPESCRIPT_ES_CONFIG_FILE: eslint.config.js
29+
TYPESCRIPT_ES_COMMAND_REMOVE_ARGUMENTS:
30+
- "--no-eslintrc"
2531

2632
# Global exclusions
2733
FILTER_REGEX_EXCLUDE: "(node_modules/|dist/|build/|\\.git/)"
2834

2935
# Skip generated code for ESLint (39k lines of schemas + 2k lines of cloud APIs)
30-
TYPESCRIPT_ESLINT_FILTER_REGEX_EXCLUDE: "(src/es/apis/schemas/|src/cloud/apis/)"
36+
TYPESCRIPT_ES_FILTER_REGEX_EXCLUDE: "(src/es/apis/schemas/|src/cloud/apis/)"
3137

3238
# yamllint: skip test fixtures (custom multi-doc YAML DSL)
3339
YAML_YAMLLINT_CONFIG_FILE: .yamllint.yml

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,41 @@ auth:
106106

107107
To store a value: `security add-generic-password -s elastic-cli -a api-key -w`
108108

109+
#### `secret_service` - freedesktop Secret Service (Linux only)
110+
111+
Reads a secret from GNOME Keyring or KWallet via `secret-tool`. Uses the same
112+
`service/account` format as `keychain`.
113+
114+
```yaml
115+
auth:
116+
api_key: $(secret_service:elastic-cli/api-key)
117+
```
118+
119+
To store a value: `secret-tool store --label='Elastic API Key' service elastic-cli account api-key`
120+
121+
#### `pass` - standard Unix password manager (cross-platform)
122+
123+
Reads the first line from `pass show`. Works on Linux, macOS, and Windows (WSL).
124+
125+
```yaml
126+
auth:
127+
api_key: $(pass:elastic/api-key)
128+
```
129+
130+
To store a value: `pass insert elastic/api-key`
131+
132+
#### `credential_manager` - Windows Credential Manager (Windows only)
133+
134+
Reads a credential from Windows Credential Manager using the `service/account`
135+
format. Requires the `CredentialManager` PowerShell module.
136+
137+
```yaml
138+
auth:
139+
api_key: $(credential_manager:elastic-cli/api-key)
140+
```
141+
142+
To store a value: `New-StoredCredential -Target elastic-cli/api-key -UserName _ -Password <key>`
143+
109144
Expressions can appear in any string field, including URLs:
110145

111146
```yaml

src/config/resolvers.ts

Lines changed: 112 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ function shellEscape (value: string): string {
101101
return "'" + value.replace(/'/g, "'\\''") + "'"
102102
}
103103

104+
function psEncodedCommand (expression: string): string {
105+
const utf16le = Buffer.from(expression, 'utf16le')
106+
return `powershell -NoProfile -NonInteractive -EncodedCommand ${utf16le.toString('base64')}`
107+
}
108+
104109
const MAX_FILE_SIZE = 64 * 1024 // 64 KB
105110

106111
function fileResolver (params: string): string {
@@ -137,42 +142,136 @@ function cmdResolver (params: string): string {
137142
}
138143
}
139144

145+
const PRINTABLE_ASCII_RE = /^[\x20-\x7e]+$/
146+
147+
function parseServiceAccount (params: string, resolverName: string): { service: string; account: string } {
148+
if (!PRINTABLE_ASCII_RE.test(params)) {
149+
throw new Error(
150+
`Invalid ${resolverName} parameter "${params}": contains non-printable characters`
151+
)
152+
}
153+
154+
const slashIndex = params.indexOf('/')
155+
if (slashIndex === -1 || slashIndex === 0 || slashIndex === params.length - 1) {
156+
throw new Error(
157+
`Invalid ${resolverName} parameter "${params}": expected format "service/account" ` +
158+
`(e.g., "elastic-cli/api-key")`
159+
)
160+
}
161+
162+
return { service: params.slice(0, slashIndex), account: params.slice(slashIndex + 1) }
163+
}
164+
140165
function keychainResolver (params: string): string {
141166
if (_platform !== 'darwin') {
142167
throw new Error(
143168
`The keychain resolver is only supported on macOS (current platform: ${_platform})`
144169
)
145170
}
146171

147-
if (!/^[\x20-\x7e]+$/.test(params)) {
172+
const { service, account } = parseServiceAccount(params, 'keychain')
173+
174+
try {
175+
return _execSync(
176+
`security find-generic-password -s ${shellEscape(service)} -a ${shellEscape(account)} -w`,
177+
execOpts(5_000)
178+
).trimEnd()
179+
} catch (err) {
180+
const message = err instanceof Error ? err.message : String(err)
148181
throw new Error(
149-
`Invalid keychain parameter "${params}": contains non-printable characters`
182+
`Keychain lookup failed for service="${service}", account="${account}": ${message}`,
183+
{ cause: err }
150184
)
151185
}
186+
}
152187

153-
const slashIndex = params.indexOf('/')
154-
if (slashIndex === -1 || slashIndex === 0 || slashIndex === params.length - 1) {
188+
function secretServiceResolver (params: string): string {
189+
if (_platform !== 'linux') {
155190
throw new Error(
156-
`Invalid keychain parameter "${params}": expected format "service/account" ` +
157-
`(e.g., "elastic-cli/api-key")`
191+
`The secret_service resolver is only supported on Linux (current platform: ${_platform})`
158192
)
159193
}
160194

161-
const service = params.slice(0, slashIndex)
162-
const account = params.slice(slashIndex + 1)
195+
const { service, account } = parseServiceAccount(params, 'secret_service')
163196

164197
try {
165198
return _execSync(
166-
`security find-generic-password -s ${shellEscape(service)} -a ${shellEscape(account)} -w`,
199+
`secret-tool lookup service ${shellEscape(service)} account ${shellEscape(account)}`,
167200
execOpts(5_000)
168201
).trimEnd()
169202
} catch (err) {
170203
const message = err instanceof Error ? err.message : String(err)
171204
throw new Error(
172-
`Keychain lookup failed for service="${service}", account="${account}": ${message}`,
205+
`Secret Service lookup failed for service="${service}", account="${account}": ${message}`,
206+
{ cause: err }
207+
)
208+
}
209+
}
210+
211+
function passResolver (params: string): string {
212+
const path = params.trim()
213+
if (path === '') {
214+
throw new Error('Invalid pass parameter: path must not be empty')
215+
}
216+
if (!PRINTABLE_ASCII_RE.test(path)) {
217+
throw new Error(
218+
`Invalid pass parameter "${path}": contains non-printable characters`
219+
)
220+
}
221+
222+
let output: string
223+
try {
224+
output = _execSync(
225+
`pass show ${shellEscape(path)}`,
226+
execOpts(5_000)
227+
).trimEnd()
228+
} catch (err) {
229+
const message = err instanceof Error ? err.message : String(err)
230+
throw new Error(
231+
`pass lookup failed for "${path}": ${message}`,
173232
{ cause: err }
174233
)
175234
}
235+
236+
const nl = output.indexOf('\n')
237+
const firstLine = nl === -1 ? output : output.slice(0, nl)
238+
if (firstLine === '') {
239+
throw new Error(`pass returned empty output for "${path}"`)
240+
}
241+
return firstLine
242+
}
243+
244+
function credentialManagerResolver (params: string): string {
245+
if (_platform !== 'win32') {
246+
throw new Error(
247+
`The credential_manager resolver is only supported on Windows (current platform: ${_platform})`
248+
)
249+
}
250+
251+
const { service, account } = parseServiceAccount(params, 'credential_manager')
252+
const target = `${service}/${account}`
253+
const expression = `(Get-StoredCredential -Target '${target.replace(/'/g, "''")}').GetNetworkCredential().Password`
254+
255+
let result: string
256+
try {
257+
result = _execSync(
258+
psEncodedCommand(expression),
259+
execOpts(10_000)
260+
).trimEnd()
261+
} catch (err) {
262+
const message = err instanceof Error ? err.message : String(err)
263+
throw new Error(
264+
`Credential Manager lookup failed for service="${service}", account="${account}": ${message}`,
265+
{ cause: err }
266+
)
267+
}
268+
269+
if (result === '') {
270+
throw new Error(
271+
`Credential Manager returned empty password for service="${service}", account="${account}"`
272+
)
273+
}
274+
return result
176275
}
177276

178277
// ---------------------------------------------------------------------------
@@ -184,6 +283,9 @@ function registerBuiltins (): void {
184283
registerResolver('env', envResolver)
185284
registerResolver('cmd', cmdResolver)
186285
registerResolver('keychain', keychainResolver)
286+
registerResolver('secret_service', secretServiceResolver)
287+
registerResolver('pass', passResolver)
288+
registerResolver('credential_manager', credentialManagerResolver)
187289
}
188290

189291
registerBuiltins()

0 commit comments

Comments
 (0)