diff --git a/README.md b/README.md index dbdf747d994..e16fbbcb54f 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,46 @@ const k8sApi = kc.makeApiClient(k8s.CoreV1Api); ... ``` +## Exec credential plugin policy + +Kubeconfig `exec` credential plugins start local processes. By default all plugins are allowed, +so only load trusted kubeconfigs. Applications can restrict which plugins may run with the same +policy that kubectl reads from its [kuberc](https://kubernetes.io/docs/reference/kubectl/kuberc/#credential-plugin-policy) +file: + +```javascript +const kc = new k8s.KubeConfig(); +kc.loadFromDefault(); + +// Never run exec plugins +kc.setExecCredentialPluginPolicy({ credentialPluginPolicy: 'DenyAll' }); + +// Only run approved plugins, by basename or full path +kc.setExecCredentialPluginPolicy({ + credentialPluginPolicy: 'Allowlist', + credentialPluginAllowlist: [{ command: 'kubelogin' }, { command: '/usr/local/bin/my-trusted-plugin' }], +}); + +// Or use the policy from the user's kuberc file ($KUBERC or ~/.kube/kuberc) +kc.loadExecCredentialPluginPolicyFromKuberc(); +``` + +The policy semantics match kubectl: + +- An unspecified or empty `credentialPluginPolicy` is the same as `AllowAll`. +- `credentialPluginAllowlist` is required (and must be non-empty) for `Allowlist`, and is an error for any other + policy. Entries must specify a non-empty `command` (the deprecated `name` alias is accepted, but not both). +- A plugin is allowed if its command exactly matches an allowlist entry, or if both resolve to the same executable + using `PATH`. Neither symlinks nor shell globs are resolved. +- In `Allowlist` mode the resolved full path of the plugin is executed, so the plugin `env` from the kubeconfig cannot + change which executable is started. Plugin arguments and environment are not otherwise restricted. +- Denied plugins throw an `ExecCredentialPluginDeniedError` before any process is started; the policy is checked + for the initial execution and every token refresh. +- `KUBERC=off` or `KUBECTL_KUBERC=false` disables loading the kuberc file. + +This policy is a defense-in-depth measure, not a sandbox. Allowing an interpreter or other general purpose launcher +can permit arbitrary code execution. + # Documentation 📖 **[View Documentation](https://kubernetes-client.github.io/javascript/)** @@ -120,7 +160,7 @@ npm start # opens http://localhost:3000 with hot-reload `npm start` automatically runs the `prestart` hook which generates the API reference, SDK docs, and model pages from source before launching the dev -server. Changes to hand-written docs (e.g. `docs/docs/examples/`) are +server. Changes to hand-written docs (e.g. `docs/docs/examples/`) are reflected instantly; changes to the generated sources require restarting the server. diff --git a/src/config.ts b/src/config.ts index fc923d30efd..eb6c533ff1f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -28,6 +28,11 @@ import { User, } from './config_types.js'; import { ExecAuth } from './exec_auth.js'; +import { + ExecCredentialPluginPolicy, + parseKubercExecCredentialPluginPolicy, + validateExecCredentialPluginPolicy, +} from './exec_credential_policy.js'; import { FileAuth } from './file_auth.js'; import { GoogleCloudPlatformAuth } from './gcp_auth.js'; import { @@ -135,10 +140,12 @@ function createSocksConnector(proxyUrl: string, tlsOptions: tls.ConnectionOption export interface ApiType {} export class KubeConfig implements SecurityAuthentication { + private execAuth: ExecAuth = new ExecAuth(); + private authenticators: Authenticator[] = [ new AzureAuth(), new GoogleCloudPlatformAuth(), - new ExecAuth(), + this.execAuth, new FileAuth(), new OpenIDConnectAuth(), ]; @@ -158,6 +165,59 @@ export class KubeConfig implements SecurityAuthentication { this.custom_authenticators.push(authenticator); } + /** + * Sets the application policy for kubeconfig `exec` credential plugins. + * The semantics match the `credentialPluginPolicy` and `credentialPluginAllowlist` + * fields of a kubectl kuberc file, see https://kubernetes.io/docs/reference/kubectl/kuberc/ + * The policy is checked before every plugin execution, including token refreshes. + * @param policy the policy, undefined restores the default `AllowAll` behavior. + * @throws Error if the policy is misconfigured. + */ + public setExecCredentialPluginPolicy(policy?: ExecCredentialPluginPolicy): void { + validateExecCredentialPluginPolicy(policy); + this.execAuth.setPolicy(policy); + } + + /** + * Loads the exec credential plugin policy from a kubectl kuberc file and applies it + * with `setExecCredentialPluginPolicy`. + * + * If `file` is not given, the file is located the same way kubectl does: the `KUBERC` + * environment variable if set, otherwise `$HOME/.kube/kuberc`. Setting `KUBERC=off` or + * `KUBECTL_KUBERC=false` disables loading, in which case the current policy is unchanged. + * A missing default kuberc file is not an error, a missing explicitly specified file is. + * @param file optional path to the kuberc file. + * @returns the loaded policy, or undefined if no kuberc file was loaded. + */ + public loadExecCredentialPluginPolicyFromKuberc( + file?: string, + platform: string = process.platform, + ): ExecCredentialPluginPolicy | undefined { + let kubercFile = file; + let required = true; + if (!kubercFile) { + if (process.env.KUBECTL_KUBERC === 'false' || process.env.KUBERC === 'off') { + return undefined; + } + if (process.env.KUBERC) { + kubercFile = process.env.KUBERC; + } else { + const home = findHomeDir(platform); + if (!home) { + return undefined; + } + kubercFile = path.join(home, '.kube', 'kuberc'); + required = false; + } + } + if (!required && !fileExists(kubercFile)) { + return undefined; + } + const policy = parseKubercExecCredentialPluginPolicy(fs.readFileSync(kubercFile).toString('utf-8')); + this.setExecCredentialPluginPolicy(policy); + return policy; + } + /** * The list of all known clusters */ diff --git a/src/exec_auth.ts b/src/exec_auth.ts index 4698f839805..c094734150e 100644 --- a/src/exec_auth.ts +++ b/src/exec_auth.ts @@ -3,6 +3,11 @@ import https from 'node:https'; import { Authenticator } from './auth.js'; import { User } from './config_types.js'; +import { + checkExecCredentialPluginPolicy, + ExecCredentialPluginPolicy, + validateExecCredentialPluginPolicy, +} from './exec_credential_policy.js'; import child_process from 'node:child_process'; @@ -18,13 +23,34 @@ export interface Credential { } export class ExecAuth implements Authenticator { - private readonly tokenCache: { [key: string]: Credential | null } = {}; + private tokenCache: { [key: string]: Credential | null } = {}; + private policy?: ExecCredentialPluginPolicy; private execFn: ( command: string, args?: readonly string[], options?: child_process.SpawnOptionsWithoutStdio, ) => child_process.ChildProcessWithoutNullStreams = child_process.spawn; + /** + * @param policy optional application policy restricting which exec credential plugins may run. + * Defaults to allowing all plugins. + */ + constructor(policy?: ExecCredentialPluginPolicy) { + this.setPolicy(policy); + } + + /** + * Sets the policy restricting which exec credential plugins may run. + * The policy applies to every subsequent plugin execution, including token refreshes. + * Cached credentials are discarded when the policy changes. + * @param policy the policy, undefined allows all plugins. + */ + public setPolicy(policy?: ExecCredentialPluginPolicy): void { + validateExecCredentialPluginPolicy(policy); + this.policy = policy; + this.tokenCache = {}; + } + public isAuthProvider(user: User): boolean { if (!user) { return false; @@ -96,6 +122,7 @@ export class ExecAuth implements Authenticator { if (!exec.command) { throw new Error('No command was specified for exec authProvider!'); } + const command = checkExecCredentialPluginPolicy(this.policy, exec.command); let opts = {}; if (exec.env) { const env = { ...process.env }; @@ -108,7 +135,7 @@ export class ExecAuth implements Authenticator { let stderrData: string = ''; let savedError: Error | undefined = undefined; - const subprocess = this.execFn(exec.command, exec.args, opts); + const subprocess = this.execFn(command, exec.args, opts); subprocess.stdout.setEncoding('utf8'); subprocess.stderr.setEncoding('utf8'); diff --git a/src/exec_credential_policy.ts b/src/exec_credential_policy.ts new file mode 100644 index 00000000000..d03935be1c6 --- /dev/null +++ b/src/exec_credential_policy.ts @@ -0,0 +1,272 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import * as yaml from 'js-yaml'; + +/** + * Policy types for exec credential plugins, matching the `credentialPluginPolicy` + * field of a kubectl `kuberc` file. + * See https://kubernetes.io/docs/reference/kubectl/kuberc/#credential-plugin-policy + */ +export type ExecCredentialPluginPolicyType = 'AllowAll' | 'DenyAll' | 'Allowlist'; + +/** + * A single entry of the `credentialPluginAllowlist`. + */ +export interface ExecCredentialPluginAllowlistEntry { + /** + * The basename or full path of a credential plugin which may be executed. + */ + readonly command?: string; + /** + * Deprecated alias for `command`. Specifying both `name` and `command` is an error. + */ + readonly name?: string; +} + +/** + * Application controlled policy for kubeconfig `exec` credential plugins. + * The field names and semantics match the kubectl `kuberc` file. + */ +export interface ExecCredentialPluginPolicy { + /** + * An unspecified or empty policy is identical to `AllowAll`. + */ + readonly credentialPluginPolicy?: ExecCredentialPluginPolicyType | ''; + /** + * Required when the policy is `Allowlist`, and must not be set otherwise. + */ + readonly credentialPluginAllowlist?: readonly ExecCredentialPluginAllowlistEntry[]; +} + +const KUBERC_API_VERSIONS = ['kubectl.config.k8s.io/v1beta1', 'kubectl.config.k8s.io/v1']; +const KUBERC_KIND = 'Preference'; + +/** + * Thrown when an exec credential plugin is not permitted to run by the configured policy. + * The error is thrown before any process is started. + */ +export class ExecCredentialPluginDeniedError extends Error { + public readonly command: string; + + constructor(command: string, reason: string) { + super(`Exec credential plugin '${command}' not allowed: ${reason}`); + this.name = 'ExecCredentialPluginDeniedError'; + this.command = command; + } +} + +function cleanPath(p: string): string { + const normalized = path.normalize(p); + const root = path.parse(normalized).root; + let result = normalized; + while (result.length > root.length && (result.endsWith('/') || result.endsWith(path.sep))) { + result = result.slice(0, -1); + } + return result; +} + +function allowlistCommand(entry: ExecCredentialPluginAllowlistEntry, index: number): string { + if (entry === null || typeof entry !== 'object') { + throw new Error(`misconfigured credential plugin allowlist: invalid allowlist entry #${index + 1}`); + } + const hasCommand = entry.command !== undefined && entry.command !== null && entry.command !== ''; + const hasName = entry.name !== undefined && entry.name !== null && entry.name !== ''; + if (hasCommand && hasName) { + throw new Error( + `misconfigured credential plugin allowlist: entry #${index + 1} specifies both 'name' and 'command'`, + ); + } + const command = hasCommand ? entry.command : entry.name; + if (!hasCommand && !hasName) { + throw new Error(`misconfigured credential plugin allowlist: empty allowlist entry #${index + 1}`); + } + if (typeof command !== 'string') { + throw new Error(`misconfigured credential plugin allowlist: invalid allowlist entry #${index + 1}`); + } + const cleaned = cleanPath(command); + if (cleaned !== command) { + throw new Error(`non-normalized file path: '${command}' vs '${cleaned}'`); + } + return command; +} + +/** + * Validates an exec credential plugin policy, throwing an Error if it is misconfigured. + * @param policy the policy to validate, undefined is treated as `AllowAll`. + */ +export function validateExecCredentialPluginPolicy(policy?: ExecCredentialPluginPolicy | null): void { + if (!policy) { + return; + } + const type = policy.credentialPluginPolicy as string | null | undefined; + switch (type) { + case undefined: + case null: + case '': + case 'AllowAll': + case 'DenyAll': + if (policy.credentialPluginAllowlist !== undefined && policy.credentialPluginAllowlist !== null) { + throw new Error( + `misconfigured credential plugin allowlist: plugin policy is '${type ?? ''}' but allowlist is set`, + ); + } + return; + case 'Allowlist': { + const list = policy.credentialPluginAllowlist; + if (list === undefined || list === null) { + throw new Error("credential plugin policy set to 'Allowlist', but allowlist is unspecified"); + } + if (!Array.isArray(list)) { + throw new Error('misconfigured credential plugin allowlist: allowlist must be a list'); + } + if (list.length === 0) { + throw new Error( + "credential plugin policy set to 'Allowlist', but allowlist is empty; use 'DenyAll' policy instead", + ); + } + list.forEach((entry, ix) => allowlistCommand(entry, ix)); + return; + } + default: + throw new Error(`unknown plugin policy: '${type}'`); + } +} + +function isExecutableFile(file: string, platform: string): boolean { + try { + const stat = fs.statSync(file); + if (!stat.isFile()) { + return false; + } + return platform === 'win32' || (stat.mode & 0o111) !== 0; + } catch { + return false; + } +} + +function findExecutable(file: string, platform: string, env: NodeJS.ProcessEnv): string | null { + if (platform !== 'win32' || path.extname(file) !== '') { + if (isExecutableFile(file, platform)) { + return file; + } + if (platform !== 'win32') { + return null; + } + } + const exts = (env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter((ext) => ext.length > 0); + for (const ext of exts) { + if (isExecutableFile(file + ext, platform)) { + return file + ext; + } + } + return null; +} + +/** + * Resolves a command to the full path of an executable, similar to `exec.LookPath` in Go. + * Commands containing a path separator are resolved relative to the current directory, + * other commands are looked up in the absolute directories of `PATH`. + * Neither symlinks nor shell globs are resolved. + */ +export function lookPath( + command: string, + env: NodeJS.ProcessEnv = process.env, + platform: string = process.platform, +): string | null { + if (!command) { + return null; + } + if (path.basename(command) !== command || path.isAbsolute(command)) { + return findExecutable(path.resolve(command), platform, env); + } + const searchPath = (platform === 'win32' ? (env.Path ?? env.PATH) : env.PATH) || ''; + for (const dir of searchPath.split(path.delimiter)) { + // Relative PATH entries are ignored, they would depend on the current directory. + if (!dir || !path.isAbsolute(dir)) { + continue; + } + const found = findExecutable(path.join(dir, command), platform, env); + if (found) { + return found; + } + } + return null; +} + +/** + * Checks an exec credential plugin command against a policy. + * + * @param policy the policy to apply, undefined is treated as `AllowAll`. + * @param command the `command` of the kubeconfig exec configuration. + * @param env the environment used to resolve the command, defaults to the process environment. + * @returns the command which should be executed. For `AllowAll` this is the command unchanged; + * for `Allowlist` this is the resolved full path of the executable, so that the plugin + * environment can not change which executable is started. + * @throws ExecCredentialPluginDeniedError if the plugin is not permitted to run. + */ +export function checkExecCredentialPluginPolicy( + policy: ExecCredentialPluginPolicy | undefined | null, + command: string, + env: NodeJS.ProcessEnv = process.env, +): string { + validateExecCredentialPluginPolicy(policy); + const type = policy?.credentialPluginPolicy; + if (!type || type === 'AllowAll') { + return command; + } + if (type === 'DenyAll') { + throw new ExecCredentialPluginDeniedError(command, "policy set to 'DenyAll'"); + } + + const entries = policy!.credentialPluginAllowlist!.map((entry, ix) => allowlistCommand(entry, ix)); + const cleaned = cleanPath(command); + const resolved = lookPath(cleaned, env); + if (!resolved) { + throw new ExecCredentialPluginDeniedError( + command, + 'plugin path cannot be resolved for credential plugin allowlist check', + ); + } + if (entries.includes(command) || entries.includes(cleaned) || entries.includes(resolved)) { + return resolved; + } + for (const entry of entries) { + if (lookPath(entry, env) === resolved) { + return resolved; + } + } + throw new ExecCredentialPluginDeniedError( + command, + 'plugin is not permitted by the credential plugin allowlist', + ); +} + +/** + * Parses the credential plugin policy from the contents of a kubectl `kuberc` file. + * Other preferences in the file (aliases, defaults) are ignored. + * @param content the YAML contents of a kuberc file. + */ +export function parseKubercExecCredentialPluginPolicy(content: string): ExecCredentialPluginPolicy { + if (content.trim() === '') { + return {}; + } + const obj = yaml.load(content) as any; + if (obj === undefined || obj === null) { + return {}; + } + if (typeof obj !== 'object' || Array.isArray(obj)) { + throw new Error('invalid kuberc: expected an object'); + } + if (!KUBERC_API_VERSIONS.includes(obj.apiVersion) || obj.kind !== KUBERC_KIND) { + throw new Error( + `invalid kuberc: unsupported apiVersion '${obj.apiVersion}' or kind '${obj.kind}', expected one of ` + + `${KUBERC_API_VERSIONS.join(', ')} and kind '${KUBERC_KIND}'`, + ); + } + const policy: ExecCredentialPluginPolicy = { + credentialPluginPolicy: obj.credentialPluginPolicy, + credentialPluginAllowlist: obj.credentialPluginAllowlist, + }; + validateExecCredentialPluginPolicy(policy); + return policy; +} diff --git a/src/exec_credential_policy_test.ts b/src/exec_credential_policy_test.ts new file mode 100644 index 00000000000..e1acfdd4db6 --- /dev/null +++ b/src/exec_credential_policy_test.ts @@ -0,0 +1,447 @@ +import { after, before, describe, it } from 'node:test'; +import { deepStrictEqual, rejects, strictEqual, throws } from 'node:assert'; +import child_process from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { KubeConfig } from './config.js'; +import { ExecAuth } from './exec_auth.js'; +import { + checkExecCredentialPluginPolicy, + ExecCredentialPluginDeniedError, + ExecCredentialPluginPolicy, + lookPath, + parseKubercExecCredentialPluginPolicy, + validateExecCredentialPluginPolicy, +} from './exec_credential_policy.js'; + +const isWindows = process.platform === 'win32'; + +describe('ExecCredentialPluginPolicy', () => { + let tmpDir: string; + let binDir: string; + let otherDir: string; + let pluginPath: string; + let otherPluginPath: string; + let env: NodeJS.ProcessEnv; + + before(() => { + tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'exec-policy-'))); + binDir = path.join(tmpDir, 'bin'); + otherDir = path.join(tmpDir, 'other'); + fs.mkdirSync(binDir); + fs.mkdirSync(otherDir); + pluginPath = path.join(binDir, 'my-plugin'); + otherPluginPath = path.join(otherDir, 'my-plugin'); + fs.writeFileSync(pluginPath, '#!/bin/sh\n', { mode: 0o755 }); + fs.writeFileSync(otherPluginPath, '#!/bin/sh\n', { mode: 0o755 }); + fs.writeFileSync(path.join(binDir, 'not-executable'), '', { mode: 0o644 }); + env = { PATH: binDir }; + }); + + after(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe('validate', () => { + it('should accept valid policies', () => { + validateExecCredentialPluginPolicy(undefined); + validateExecCredentialPluginPolicy({}); + validateExecCredentialPluginPolicy({ credentialPluginPolicy: '' }); + validateExecCredentialPluginPolicy({ credentialPluginPolicy: 'AllowAll' }); + validateExecCredentialPluginPolicy({ credentialPluginPolicy: 'DenyAll' }); + validateExecCredentialPluginPolicy({ + credentialPluginPolicy: 'Allowlist', + credentialPluginAllowlist: [{ command: 'foo' }, { command: '/usr/bin/bar' }, { name: 'baz' }], + }); + }); + + it('should reject an unknown policy', () => { + throws( + () => validateExecCredentialPluginPolicy({ credentialPluginPolicy: 'Foo' as any }), + /unknown plugin policy/, + ); + }); + + it('should reject an allowlist with a non Allowlist policy', () => { + for (const credentialPluginPolicy of [undefined, '', 'AllowAll', 'DenyAll'] as const) { + throws( + () => + validateExecCredentialPluginPolicy({ + credentialPluginPolicy, + credentialPluginAllowlist: [{ command: 'foo' }], + }), + /allowlist is set/, + ); + throws( + () => + validateExecCredentialPluginPolicy({ + credentialPluginPolicy, + credentialPluginAllowlist: [], + }), + /allowlist is set/, + ); + } + }); + + it('should reject a missing or empty allowlist', () => { + throws( + () => validateExecCredentialPluginPolicy({ credentialPluginPolicy: 'Allowlist' }), + /allowlist is unspecified/, + ); + throws( + () => + validateExecCredentialPluginPolicy({ + credentialPluginPolicy: 'Allowlist', + credentialPluginAllowlist: [], + }), + /allowlist is empty/, + ); + }); + + it('should reject invalid allowlist entries', () => { + const check = (entry: any, msg: RegExp) => + throws( + () => + validateExecCredentialPluginPolicy({ + credentialPluginPolicy: 'Allowlist', + credentialPluginAllowlist: [{ command: 'ok' }, entry], + }), + msg, + ); + check({}, /empty allowlist entry #2/); + check({ command: '' }, /empty allowlist entry #2/); + check({ name: 'foo', command: 'foo' }, /both 'name' and 'command'/); + check({ command: './foo' }, /non-normalized/); + check({ command: '/usr/bin/../bin/foo' }, /non-normalized/); + check(null, /invalid allowlist entry #2/); + }); + }); + + describe('lookPath', { skip: isWindows }, () => { + it('should resolve basenames using PATH', () => { + strictEqual(lookPath('my-plugin', env), pluginPath); + strictEqual( + lookPath('my-plugin', { PATH: [otherDir, binDir].join(path.delimiter) }), + otherPluginPath, + ); + strictEqual(lookPath('missing', env), null); + strictEqual(lookPath('not-executable', env), null); + strictEqual(lookPath('my-plugin', {}), null); + }); + + it('should ignore relative PATH entries', () => { + strictEqual(lookPath('my-plugin', { PATH: path.relative(process.cwd(), binDir) }), null); + }); + + it('should resolve paths', () => { + strictEqual(lookPath(pluginPath, {}), pluginPath); + strictEqual(lookPath(path.relative(process.cwd(), pluginPath), {}), pluginPath); + strictEqual(lookPath(binDir, {}), null); + }); + }); + + describe('check', () => { + it('should allow everything by default', () => { + strictEqual(checkExecCredentialPluginPolicy(undefined, 'anything', env), 'anything'); + strictEqual(checkExecCredentialPluginPolicy({}, 'anything', env), 'anything'); + strictEqual( + checkExecCredentialPluginPolicy({ credentialPluginPolicy: 'AllowAll' }, 'anything', env), + 'anything', + ); + }); + + it('should deny everything with DenyAll', () => { + throws( + () => + checkExecCredentialPluginPolicy({ credentialPluginPolicy: 'DenyAll' }, 'my-plugin', env), + (err: Error) => err instanceof ExecCredentialPluginDeniedError && /DenyAll/.test(err.message), + ); + }); + + it('should reject misconfigured policies', () => { + throws(() => + checkExecCredentialPluginPolicy({ credentialPluginPolicy: 'Allowlist' }, 'my-plugin', env), + ); + }); + + describe('Allowlist', { skip: isWindows }, () => { + const allowlist = (...commands: string[]): ExecCredentialPluginPolicy => ({ + credentialPluginPolicy: 'Allowlist', + credentialPluginAllowlist: commands.map((command) => ({ command })), + }); + + it('should allow an exact basename match and return the resolved path', () => { + strictEqual( + checkExecCredentialPluginPolicy(allowlist('my-plugin'), 'my-plugin', env), + pluginPath, + ); + }); + + it('should allow the deprecated name field', () => { + strictEqual( + checkExecCredentialPluginPolicy( + { + credentialPluginPolicy: 'Allowlist', + credentialPluginAllowlist: [{ name: 'my-plugin' }], + }, + 'my-plugin', + env, + ), + pluginPath, + ); + }); + + it('should allow a full path entry when the command resolves to it', () => { + strictEqual( + checkExecCredentialPluginPolicy(allowlist(pluginPath), 'my-plugin', env), + pluginPath, + ); + strictEqual( + checkExecCredentialPluginPolicy(allowlist(pluginPath), pluginPath, env), + pluginPath, + ); + }); + + it('should allow a full path command when a basename entry resolves to it', () => { + strictEqual( + checkExecCredentialPluginPolicy(allowlist('my-plugin'), pluginPath, env), + pluginPath, + ); + }); + + it('should deny a same-named executable in another directory', () => { + throws( + () => checkExecCredentialPluginPolicy(allowlist('my-plugin'), otherPluginPath, env), + ExecCredentialPluginDeniedError, + ); + throws( + () => checkExecCredentialPluginPolicy(allowlist(otherPluginPath), 'my-plugin', env), + ExecCredentialPluginDeniedError, + ); + }); + + it('should deny plugins not in the allowlist', () => { + throws( + () => checkExecCredentialPluginPolicy(allowlist('foo', '/usr/bin/bar'), 'my-plugin', env), + /not permitted by the credential plugin allowlist/, + ); + }); + + it('should deny plugins which cannot be resolved', () => { + throws( + () => checkExecCredentialPluginPolicy(allowlist('missing'), 'missing', env), + /cannot be resolved/, + ); + }); + + it('should not resolve symlinks', () => { + const link = path.join(otherDir, 'link-plugin'); + fs.symlinkSync(pluginPath, link); + try { + throws( + () => checkExecCredentialPluginPolicy(allowlist(link), pluginPath, env), + ExecCredentialPluginDeniedError, + ); + strictEqual(checkExecCredentialPluginPolicy(allowlist(link), link, env), link); + } finally { + fs.unlinkSync(link); + } + }); + }); + }); + + describe('kuberc', () => { + it('should parse the credential plugin policy', () => { + const policy = parseKubercExecCredentialPluginPolicy(` +apiVersion: kubectl.config.k8s.io/v1beta1 +kind: Preference +defaults: + - command: delete + options: + - name: interactive + default: "true" +credentialPluginPolicy: Allowlist +credentialPluginAllowlist: + - command: my-trusted-binary + - command: /usr/local/bin/my-other-trusted-binary +`); + deepStrictEqual(policy, { + credentialPluginPolicy: 'Allowlist', + credentialPluginAllowlist: [ + { command: 'my-trusted-binary' }, + { command: '/usr/local/bin/my-other-trusted-binary' }, + ], + }); + }); + + it('should default to AllowAll', () => { + deepStrictEqual( + parseKubercExecCredentialPluginPolicy( + 'apiVersion: kubectl.config.k8s.io/v1beta1\nkind: Preference\n', + ), + { credentialPluginPolicy: undefined, credentialPluginAllowlist: undefined }, + ); + deepStrictEqual(parseKubercExecCredentialPluginPolicy(''), {}); + }); + + it('should reject invalid kuberc files', () => { + throws( + () => parseKubercExecCredentialPluginPolicy('apiVersion: v1\nkind: Config\n'), + /invalid kuberc/, + ); + throws( + () => + parseKubercExecCredentialPluginPolicy( + 'apiVersion: kubectl.config.k8s.io/v1beta1\nkind: Preference\ncredentialPluginPolicy: Allowlist\ncredentialPluginAllowlist:\n - command: ""\n', + ), + /empty allowlist entry/, + ); + throws( + () => + parseKubercExecCredentialPluginPolicy( + 'apiVersion: kubectl.config.k8s.io/v1beta1\nkind: Preference\ncredentialPluginPolicy: DenyAll\ncredentialPluginAllowlist:\n - command: foo\n', + ), + /allowlist is set/, + ); + }); + + describe('KubeConfig.loadExecCredentialPluginPolicyFromKuberc', () => { + const saved = { + HOME: process.env.HOME, + KUBERC: process.env.KUBERC, + KUBECTL_KUBERC: process.env.KUBECTL_KUBERC, + }; + const restore = () => { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + Reflect.deleteProperty(process.env, key); + } else { + process.env[key] = value; + } + } + }; + const denyAll = + 'apiVersion: kubectl.config.k8s.io/v1beta1\nkind: Preference\ncredentialPluginPolicy: DenyAll\n'; + + after(restore); + + it('should load an explicit file', () => { + const file = path.join(tmpDir, 'explicit-kuberc'); + fs.writeFileSync(file, denyAll); + const kc = new KubeConfig(); + deepStrictEqual(kc.loadExecCredentialPluginPolicyFromKuberc(file), { + credentialPluginPolicy: 'DenyAll', + credentialPluginAllowlist: undefined, + }); + throws(() => kc.loadExecCredentialPluginPolicyFromKuberc(path.join(tmpDir, 'missing'))); + }); + + it('should honor KUBERC and the default location', { skip: isWindows }, () => { + const home = path.join(tmpDir, 'home'); + fs.mkdirSync(path.join(home, '.kube'), { recursive: true }); + process.env.HOME = home; + delete process.env.KUBERC; + delete process.env.KUBECTL_KUBERC; + const kc = new KubeConfig(); + + // missing default file is not an error + strictEqual(kc.loadExecCredentialPluginPolicyFromKuberc(), undefined); + + fs.writeFileSync(path.join(home, '.kube', 'kuberc'), denyAll); + strictEqual(kc.loadExecCredentialPluginPolicyFromKuberc()?.credentialPluginPolicy, 'DenyAll'); + + process.env.KUBERC = 'off'; + strictEqual(kc.loadExecCredentialPluginPolicyFromKuberc(), undefined); + + process.env.KUBERC = path.join(tmpDir, 'missing'); + throws(() => kc.loadExecCredentialPluginPolicyFromKuberc()); + + delete process.env.KUBERC; + process.env.KUBECTL_KUBERC = 'false'; + strictEqual(kc.loadExecCredentialPluginPolicyFromKuberc(), undefined); + restore(); + }); + }); + }); + + describe('ExecAuth', () => { + const user = (command: string, envVars?: { name: string; value: string }[]) => ({ + name: 'user', + exec: { command, env: envVars }, + }); + const fakeExec = (calls: string[]) => (command: string) => { + calls.push(command); + return { + stdout: { + setEncoding: () => {}, + on: (_data: string, f: (data: string) => void) => { + f(JSON.stringify({ status: { token: 'foo' } })); + }, + }, + stderr: { setEncoding: () => {}, on: () => {} }, + on: (op: string, f: any) => { + if (op === 'close') { + f(0); + } + }, + } as unknown as child_process.ChildProcessWithoutNullStreams; + }; + + it('should not start a process when denied', async () => { + const auth = new ExecAuth({ credentialPluginPolicy: 'DenyAll' }); + const calls: string[] = []; + (auth as any).execFn = fakeExec(calls); + await rejects(auth.applyAuthentication(user('my-plugin'), {}), ExecCredentialPluginDeniedError); + deepStrictEqual(calls, []); + }); + + it('should reject invalid policies', () => { + throws(() => new ExecAuth({ credentialPluginPolicy: 'Allowlist' })); + const kc = new KubeConfig(); + throws(() => kc.setExecCredentialPluginPolicy({ credentialPluginPolicy: 'Allowlist' })); + }); + + it('should run the resolved path for allowlisted plugins', { skip: isWindows }, async () => { + const savedPath = process.env.PATH; + process.env.PATH = binDir; + try { + const auth = new ExecAuth({ + credentialPluginPolicy: 'Allowlist', + credentialPluginAllowlist: [{ command: 'my-plugin' }], + }); + const calls: string[] = []; + (auth as any).execFn = fakeExec(calls); + const opts = { headers: {} } as any; + // a kubeconfig supplied PATH must not change which executable is started + await auth.applyAuthentication(user('my-plugin', [{ name: 'PATH', value: otherDir }]), opts); + deepStrictEqual(calls, [pluginPath]); + strictEqual(opts.headers.Authorization, ['Bearer', 'foo'].join(' ')); + } finally { + process.env.PATH = savedPath; + } + }); + + it('should apply policy changes to cached credentials', async () => { + const auth = new ExecAuth(); + const calls: string[] = []; + (auth as any).execFn = fakeExec(calls); + await auth.applyAuthentication(user('my-plugin'), {}); + strictEqual(calls.length, 1); + auth.setPolicy({ credentialPluginPolicy: 'DenyAll' }); + await rejects(auth.applyAuthentication(user('my-plugin'), {}), ExecCredentialPluginDeniedError); + strictEqual(calls.length, 1); + }); + + it('should be applied by KubeConfig', async () => { + const kc = new KubeConfig(); + kc.loadFromClusterAndUser( + { name: 'c', server: 'https://localhost', skipTLSVerify: false }, + user('my-plugin') as any, + ); + kc.setExecCredentialPluginPolicy({ credentialPluginPolicy: 'DenyAll' }); + await rejects(kc.applyToHTTPSOptions({}), ExecCredentialPluginDeniedError); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 746abcb71c2..b188253ea46 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,3 +19,11 @@ export * from './health.js'; export * from './middleware.js'; export * from './patch.js'; export { type ConfigOptions, type User, type Cluster, type Context } from './config_types.js'; +export { + type ExecCredentialPluginPolicy, + type ExecCredentialPluginPolicyType, + type ExecCredentialPluginAllowlistEntry, + ExecCredentialPluginDeniedError, + validateExecCredentialPluginPolicy, + parseKubercExecCredentialPluginPolicy, +} from './exec_credential_policy.js';