diff --git a/src/commands/config/cmd-config-get.test.ts b/src/commands/config/cmd-config-get.test.ts index c9c869e87..2752e4d19 100644 --- a/src/commands/config/cmd-config-get.test.ts +++ b/src/commands/config/cmd-config-get.test.ts @@ -104,4 +104,224 @@ describe('socket config get', async () => { expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) } ) + + describe('env vars', () => { + describe('token', () => { + cmdit( + ['config', 'get', 'apiToken', '--config', '{"apiToken":null}'], + 'should return undefined when token not set in config', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd, {}) + expect(stdout).toMatchInlineSnapshot( + ` + "apiToken: null + + Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." + ` + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: " + `) + + expect(stdout.includes('apiToken: null')).toBe(true) + } + ) + + cmdit( + ['config', 'get', 'apiToken', '--config', '{"apiToken":null}'], + 'should return the env var token when set', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd, { + SOCKET_SECURITY_API_TOKEN: 'abc' + }) + expect(stdout).toMatchInlineSnapshot( + ` + "apiToken: abc + + Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." + ` + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: " + `) + + expect(stdout.includes('apiToken: abc')).toBe(true) + } + ) + + // Migrate this away...? + cmdit( + ['config', 'get', 'apiToken', '--config', '{"apiToken":null}'], + 'should backwards compat support api key as well env var', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd, { + SOCKET_SECURITY_API_KEY: 'abc' + }) + expect(stdout).toMatchInlineSnapshot( + ` + "apiToken: abc + + Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." + ` + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: " + `) + + expect(stdout.includes('apiToken: abc')).toBe(true) + } + ) + + cmdit( + ['config', 'get', 'apiToken', '--config', '{"apiToken":null}'], + 'should be nice and support cli prefixed env var for token as well', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd, { + SOCKET_CLI_API_TOKEN: 'abc' + }) + expect(stdout).toMatchInlineSnapshot( + ` + "apiToken: abc + + Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." + ` + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: " + `) + + expect(stdout.includes('apiToken: abc')).toBe(true) + } + ) + + // Migrate this away...? + cmdit( + ['config', 'get', 'apiToken', '--config', '{"apiToken":null}'], + 'should be very nice and support cli prefixed env var for key as well since it is an easy mistake to make', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd, { + SOCKET_CLI_API_KEY: 'abc' + }) + expect(stdout).toMatchInlineSnapshot( + ` + "apiToken: abc + + Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." + ` + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: " + `) + + expect(stdout.includes('apiToken: abc')).toBe(true) + } + ) + + cmdit( + [ + 'config', + 'get', + 'apiToken', + '--config', + '{"apiToken":"ignoremebecausetheenvvarshouldbemoreimportant"}' + ], + 'should use the env var token when the config override also has a token set', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd, { + SOCKET_CLI_API_KEY: 'abc' + }) + expect(stdout).toMatchInlineSnapshot( + ` + "apiToken: abc + + Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." + ` + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: " + `) + + expect(stdout.includes('apiToken: abc')).toBe(true) + } + ) + + cmdit( + [ + 'config', + 'get', + 'apiToken', + '--config', + '{"apiToken":"pickmepickme"}' + ], + 'should use the config override when there is no env var', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd, {}) + expect(stdout).toMatchInlineSnapshot( + ` + "apiToken: pickmepickme + + Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." + ` + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: " + `) + + expect(stdout.includes('apiToken: pickmepickme')).toBe(true) + } + ) + + cmdit( + ['config', 'get', 'apiToken', '--config', '{}'], + 'should yield no token when override has none', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd, {}) + expect(stdout).toMatchInlineSnapshot( + ` + "apiToken: undefined + + Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." + ` + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: " + `) + + expect(stdout.includes('apiToken: undefined')).toBe(true) + } + ) + }) + }) }) diff --git a/src/commands/config/cmd-config.test.ts b/src/commands/config/cmd-config.test.ts index 05d30cc44..083fd5f30 100644 --- a/src/commands/config/cmd-config.test.ts +++ b/src/commands/config/cmd-config.test.ts @@ -1,6 +1,6 @@ import path from 'node:path' -import { describe, expect } from 'vitest' +import { describe, expect, it } from 'vitest' import constants from '../../../dist/constants.js' import { cmdit, invokeNpm } from '../../../test/utils' @@ -39,12 +39,12 @@ describe('socket config', async () => { ` ) expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | Socket.dev CLI ver - |__ | . | _| '_| -_| _| | Node: , API token set: - |_____|___|___|_,_|___|_|.dev | Command: \`socket config\`, cwd: " - `) + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket config\`, cwd: " + `) expect(code, 'help should exit with code 2').toBe(2) expect(stderr, 'banner includes base command').toContain( @@ -72,4 +72,27 @@ describe('socket config', async () => { expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) } ) + + describe('config override', () => { + cmdit( + ['config', 'get', 'apiToken', '--config', '{apiToken:invalidjson}'], + 'should print nice error when config override cannot be parsed', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(entryPath, cmd, {}) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver + |__ | . | _| '_| -_| _| | Node: , API token set: + |_____|___|___|_,_|___|_|.dev | Command: \`socket\`, cwd: + + \\x1b[31m\\xd7\\x1b[39m Could not JSON parse the config override. Make sure it's a proper JSON object (double-quoted keys and strings, no unquoted \`undefined\`) and try again." + `) + + expect(stderr.includes('Could not JSON parse')).toBe(true) + expect(code, 'bad config input should exit with code 2 ').toBe(2) + } + ) + }) }) diff --git a/src/commands/config/handle-config-get.ts b/src/commands/config/handle-config-get.ts index c469524bc..44ddfbb03 100644 --- a/src/commands/config/handle-config-get.ts +++ b/src/commands/config/handle-config-get.ts @@ -1,5 +1,5 @@ import { outputConfigGet } from './output-config-get' -import { getConfigValue } from '../../utils/config' +import { getConfigValue, isReadOnlyConfig } from '../../utils/config' import type { LocalConfig } from '../../utils/config' @@ -11,6 +11,7 @@ export async function handleConfigGet({ outputKind: 'json' | 'markdown' | 'text' }) { const value = getConfigValue(key) + const readOnly = isReadOnlyConfig() - await outputConfigGet(key, value, outputKind) + await outputConfigGet(key, value, readOnly, outputKind) } diff --git a/src/commands/config/handle-config-set.ts b/src/commands/config/handle-config-set.ts index 566a36334..e0c5b18f9 100644 --- a/src/commands/config/handle-config-set.ts +++ b/src/commands/config/handle-config-set.ts @@ -1,5 +1,5 @@ import { outputConfigSet } from './output-config-set' -import { updateConfigValue } from '../../utils/config' +import { isReadOnlyConfig, updateConfigValue } from '../../utils/config' import type { LocalConfig } from '../../utils/config' @@ -13,5 +13,7 @@ export async function handleConfigSet({ value: string }) { updateConfigValue(key, value) - await outputConfigSet(key, value, outputKind) + const readOnly = isReadOnlyConfig() + + await outputConfigSet(key, value, readOnly, outputKind) } diff --git a/src/commands/config/output-config-get.ts b/src/commands/config/output-config-get.ts index 7df89134c..50b2bdba8 100644 --- a/src/commands/config/output-config-get.ts +++ b/src/commands/config/output-config-get.ts @@ -5,15 +5,30 @@ import { LocalConfig } from '../../utils/config' export async function outputConfigGet( key: keyof LocalConfig, value: unknown, + readOnly: boolean, // Is config in read-only mode? (Overrides applied) outputKind: 'json' | 'markdown' | 'text' ) { if (outputKind === 'json') { - logger.log(JSON.stringify({ success: true, result: { key, value } })) + logger.log( + JSON.stringify({ success: true, result: { key, value }, readOnly }) + ) } else if (outputKind === 'markdown') { logger.log(`# Config Value`) logger.log('') logger.log(`Config key '${key}' has value '${value}`) + if (readOnly) { + logger.log('') + logger.log( + 'Note: the config is in read-only mode, meaning at least one key was temporarily\n overridden from an env var or command flag.' + ) + } } else { logger.log(`${key}: ${value}`) + if (readOnly) { + logger.log('') + logger.log( + 'Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag.' + ) + } } } diff --git a/src/commands/config/output-config-list.ts b/src/commands/config/output-config-list.ts index ccda6428e..ebf0d0102 100644 --- a/src/commands/config/output-config-list.ts +++ b/src/commands/config/output-config-list.ts @@ -2,6 +2,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { getConfigValue, + isReadOnlyConfig, sensitiveConfigKeys, supportedConfigKeys } from '../../utils/config' @@ -13,6 +14,7 @@ export async function outputConfigList({ full: boolean outputKind: 'json' | 'markdown' | 'text' }) { + const readOnly = isReadOnlyConfig() if (outputKind === 'json') { const obj: Record = {} for (const key of supportedConfigKeys.keys()) { @@ -29,7 +31,8 @@ export async function outputConfigList({ { success: true, full, - config: obj + config: obj, + readOnly }, null, 2 @@ -56,5 +59,11 @@ export async function outputConfigList({ ) } } + if (readOnly) { + logger.log('') + logger.log( + 'Note: the config is in read-only mode, meaning at least one key was temporarily\n overridden from an env var or command flag.' + ) + } } } diff --git a/src/commands/config/output-config-set.ts b/src/commands/config/output-config-set.ts index 84df41f45..f71bda90d 100644 --- a/src/commands/config/output-config-set.ts +++ b/src/commands/config/output-config-set.ts @@ -5,20 +5,34 @@ import type { LocalConfig } from '../../utils/config' export async function outputConfigSet( key: keyof LocalConfig, _value: string, + readOnly: boolean, outputKind: 'json' | 'markdown' | 'text' ) { if (outputKind === 'json') { logger.log( JSON.stringify({ success: true, - message: `Config key '${key}' was updated` + message: `Config key '${key}' was updated${readOnly ? ' (Note: since at least one value was overridden from flag/env, the config was not persisted)' : ''}`, + readOnly }) ) } else if (outputKind === 'markdown') { logger.log(`# Update config`) logger.log('') logger.log(`Config key '${key}' was updated`) + if (readOnly) { + logger.log('') + logger.log( + 'Note: The change was not persisted because the config is in read-only mode,\n meaning at least one key was temporarily overridden from an env var or\n command flag.' + ) + } } else { logger.log(`OK`) + if (readOnly) { + logger.log('') + logger.log( + 'Note: The change was not persisted because the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag.' + ) + } } } diff --git a/src/commands/login/attempt-login.ts b/src/commands/login/attempt-login.ts index 12b4343c3..6ac3e8e4f 100644 --- a/src/commands/login/attempt-login.ts +++ b/src/commands/login/attempt-login.ts @@ -6,7 +6,7 @@ import { confirm, password, select } from '@socketsecurity/registry/lib/prompts' import { applyLogin } from './apply-login' import constants from '../../constants' import { handleUnsuccessfulApiResponse } from '../../utils/api' -import { getConfigValue } from '../../utils/config' +import { getConfigValue, isReadOnlyConfig } from '../../utils/config' import { setupSdk } from '../../utils/sdk' import type { Choice, Separator } from '@socketsecurity/registry/lib/prompts' @@ -86,10 +86,18 @@ export async function attemptLogin( spinner.stop() - const oldToken = getConfigValue('apiToken') + const previousPersistedToken = getConfigValue('apiToken') try { applyLogin(apiToken, enforcedOrgs, apiBaseUrl, apiProxy) - logger.success(`API credentials ${oldToken ? 'updated' : 'set'}`) + logger.success( + `API credentials ${previousPersistedToken === apiToken ? 'refreshed' : previousPersistedToken ? 'updated' : 'set'}` + ) + if (!isReadOnlyConfig()) { + logger.log('') + logger.warn( + 'Note: config is in read-only mode, at least one key was overridden through flag/env, so the login was not persisted!' + ) + } } catch { logger.fail(`API login failed`) } diff --git a/src/commands/logout/attempt-logout.ts b/src/commands/logout/attempt-logout.ts index 058e2ddc5..ffc1fe67a 100644 --- a/src/commands/logout/attempt-logout.ts +++ b/src/commands/logout/attempt-logout.ts @@ -1,11 +1,18 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { applyLogout } from './apply-logout' +import { isReadOnlyConfig } from '../../utils/config' export function attemptLogout() { try { applyLogout() logger.success('Successfully logged out') + if (!isReadOnlyConfig()) { + logger.log('') + logger.warn( + 'Note: config is in read-only mode, at least one key was overridden through flag/env, so the logout was not persisted!' + ) + } } catch { logger.fail('Failed to complete logout steps') } diff --git a/src/utils/config.ts b/src/utils/config.ts index a3a8214ed..62e62b085 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -40,16 +40,53 @@ export const supportedConfigKeys: Map = new Map([ export const sensitiveConfigKeys: Set = new Set(['apiToken']) let _cachedConfig: LocalConfig | undefined -// When using --config or SOCKET_CLI_CONFIG_OVERRIDE, do not persist the config. +// When using --config or SOCKET_CLI_CONFIG, do not persist the config. let _readOnlyConfig = false -export function overrideCachedConfig(config: object) { - _cachedConfig = { ...config } as LocalConfig + +export function overrideCachedConfig( + jsonConfig: unknown +): { ok: true; message: undefined } | { ok: false; message: string } { + let config + try { + config = JSON.parse(String(jsonConfig)) + if (!config || typeof config !== 'object') { + // Just throw to reuse the error message. `null` is valid json, + // so are primitive values. They're not valid config objects :) + throw new Error() + } + } catch { + return { + ok: false, + message: + "Could not JSON parse the config override. Make sure it's a proper JSON object (double-quoted keys and strings, no unquoted `undefined`) and try again." + } + } + + // @ts-ignore if you want to override an illegal object, so be it? + _cachedConfig = config as LocalConfig _readOnlyConfig = true + // Normalize apiKey to apiToken. if (_cachedConfig['apiKey']) { + if (_cachedConfig['apiToken']) { + logger.warn( + 'Note: The config override had both apiToken and apiKey. Using the apiToken value. Remove the apiKey to get rid of this message.' + ) + } _cachedConfig['apiToken'] = _cachedConfig['apiKey'] delete _cachedConfig['apiKey'] } + + return { ok: true, message: undefined } +} + +export function overrideConfigApiToken(apiToken: unknown) { + // Set token to the local cached config and mark it read-only so it doesn't persist + _cachedConfig = { + ...config, + ...(apiToken === undefined ? {} : { apiToken: String(apiToken) }) + } as LocalConfig + _readOnlyConfig = true } function getConfigValues(): LocalConfig { @@ -167,6 +204,9 @@ export function getConfigValue( const localConfig = getConfigValues() return localConfig[normalizeConfigKey(key)] as LocalConfig[Key] } +export function isReadOnlyConfig() { + return _readOnlyConfig +} let _pendingSave = false export function updateConfigValue( diff --git a/src/utils/meow-with-subcommands.ts b/src/utils/meow-with-subcommands.ts index c8b2cc943..6befda4ba 100644 --- a/src/utils/meow-with-subcommands.ts +++ b/src/utils/meow-with-subcommands.ts @@ -9,10 +9,11 @@ import { normalizePath } from '@socketsecurity/registry/lib/path' import { escapeRegExp } from '@socketsecurity/registry/lib/regexps' import { getLastFiveOfApiToken } from './api' -import { getConfigValue, overrideCachedConfig } from './config' +import { overrideCachedConfig, overrideConfigApiToken } from './config' import { getFlagListOutput, getHelpListOutput } from './output-formatting' import constants from '../constants' import { MeowFlags, commonFlags } from '../flags' +import { getDefaultToken } from './sdk' import type { Options } from 'meow' @@ -139,10 +140,44 @@ export async function meowWithSubcommands( // Hard override the config if instructed to do so. // The env var overrides the --flag, which overrides the persisted config // Also, when either of these are used, config updates won't persist. + let configOverrideResult if (process.env['SOCKET_CLI_CONFIG']) { - overrideCachedConfig(JSON.parse(process.env['SOCKET_CLI_CONFIG'])) + configOverrideResult = overrideCachedConfig( + process.env['SOCKET_CLI_CONFIG'] + ) } else if (cli.flags['config']) { - overrideCachedConfig(JSON.parse(String(cli.flags['config'] || ''))) + configOverrideResult = overrideCachedConfig( + String(cli.flags['config'] || '') + ) + } + + if (process.env['SOCKET_CLI_NO_API_TOKEN']) { + // This overrides the config override and even the explicit token env var. + // The config will be marked as readOnly to prevent persisting it. + overrideConfigApiToken(undefined) + } else { + // Note: these are SOCKET_SECURITY prefixed because they're not specific to + // the CLI. For the sake of consistency we'll also support the env + // keys that do have the SOCKET_CLI prefix, it's an easy mistake. + // In case multiple are supplied, the tokens supersede the keys and the + // security prefix supersedes the cli prefix. "Adventure mode" ;) + const tokenOverride = + process.env['SOCKET_CLI_API_KEY'] || + process.env['SOCKET_SECURITY_API_KEY'] || + process.env['SOCKET_CLI_API_TOKEN'] || + process.env['SOCKET_SECURITY_API_TOKEN'] + if (tokenOverride) { + // This will set the token (even if there was a config override) and + // set it to readOnly, making sure the temp token won't be persisted. + overrideConfigApiToken(tokenOverride) + } + } + + if (configOverrideResult?.ok === false) { + emitBanner(name) + logger.fail(configOverrideResult.message) + process.exitCode = 2 + return } // If we got at least some args, then lets find out if we can find a command. @@ -234,7 +269,7 @@ function getAsciiHeader(command: string) { : // The '@rollup/plugin-replace' will replace "process.env['INLINED_SOCKET_CLI_VERSION_HASH']". process.env['INLINED_SOCKET_CLI_VERSION_HASH'] const nodeVersion = redacting ? REDACTED : process.version - const apiToken = getConfigValue('apiToken') + const apiToken = getDefaultToken() const shownToken = redacting ? REDACTED : apiToken diff --git a/test/utils.ts b/test/utils.ts index b11b63640..d1abc9754 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -33,7 +33,8 @@ export function cmdit( export async function invokeNpm( entryPath: string, - args: string[] + args: string[], + env = {} ): Promise<{ status: boolean code: number @@ -46,7 +47,8 @@ export async function invokeNpm( constants.execPath, [entryPath, ...args], { - cwd: npmFixturesPath + cwd: npmFixturesPath, + env: { ...process.env, ...env } } ) return {