diff --git a/lib/commands/login/index.js b/lib/commands/login/index.js index ebfb595..46c6098 100644 --- a/lib/commands/login/index.js +++ b/lib/commands/login/index.js @@ -5,6 +5,8 @@ import prompts from 'prompts' import terminalLink from 'terminal-link' import { AuthError, InputError } from '../../utils/errors.js' +import { prepareFlags } from '../../utils/flags.js' +import { printFlagList } from '../../utils/formatting.js' import { FREE_API_KEY, setupSdk } from '../../utils/sdk.js' import { getSetting, updateSetting } from '../../utils/settings.js' @@ -14,6 +16,16 @@ const description = 'Socket API login' export const login = { description, run: async (argv, importMeta, { parentName }) => { + const flags = prepareFlags({ + apiBaseUrl: { + type: 'string', + description: 'API server to connect to for login', + }, + apiProxy: { + type: 'string', + description: 'Proxy to use when making connection to API server' + } + }) const name = parentName + ' login' const cli = meow(` Usage @@ -21,12 +33,19 @@ export const login = { Logs into the Socket API by prompting for an API key + Options + ${printFlagList({ + 'api-base-url': flags.apiBaseUrl.description, + 'api-proxy': flags.apiProxy.description + }, 8)} + Examples $ ${name} `, { argv, description, importMeta, + flags }) /** @@ -58,13 +77,27 @@ export const login = { const apiKey = result.apiKey || FREE_API_KEY + /** + * @type {string | null | undefined} + */ + let apiBaseUrl = cli.flags.apiBaseUrl + apiBaseUrl ??= getSetting('apiBaseUrl') ?? + undefined + + /** + * @type {string | null | undefined} + */ + let apiProxy = cli.flags.apiProxy + apiProxy ??= getSetting('apiProxy') ?? + undefined + const spinner = ora('Verifying API key...').start() /** @type {import('@socketsecurity/sdk').SocketSdkReturnType<'getOrganizations'>['data']} */ let orgs try { - const sdk = await setupSdk(apiKey) + const sdk = await setupSdk(apiKey, apiBaseUrl, apiProxy) const result = await sdk.getOrganizations() if (!result.success) throw new AuthError() orgs = result.data @@ -131,6 +164,7 @@ export const login = { updateSetting('enforcedOrgs', enforcedOrgs) const oldKey = getSetting('apiKey') updateSetting('apiKey', apiKey) + updateSetting('apiBaseUrl', apiBaseUrl) spinner.succeed(`API credentials ${oldKey ? 'updated' : 'set'}`) } } diff --git a/lib/commands/logout/index.js b/lib/commands/logout/index.js index cc7094e..da21e98 100644 --- a/lib/commands/logout/index.js +++ b/lib/commands/logout/index.js @@ -27,6 +27,8 @@ export const logout = { if (cli.input.length) cli.showHelp() updateSetting('apiKey', null) + updateSetting('apiBaseUrl', null) + updateSetting('apiProxy', null) updateSetting('enforcedOrgs', null) ora('Successfully logged out').succeed() } diff --git a/lib/commands/report/create.js b/lib/commands/report/create.js index e0dca88..fb976a5 100644 --- a/lib/commands/report/create.js +++ b/lib/commands/report/create.js @@ -179,7 +179,6 @@ async function setupCommand (name, description, argv, importMeta) { } }) - // TODO: setupSdk(getDefaultKey() || FREE_API_KEY) const socketSdk = await setupSdk() const supportedFiles = await socketSdk.getReportSupportedFiles() .then(res => { diff --git a/lib/shadow/npm-injection.cjs b/lib/shadow/npm-injection.cjs index 51ffcf6..aff4453 100644 --- a/lib/shadow/npm-injection.cjs +++ b/lib/shadow/npm-injection.cjs @@ -40,34 +40,50 @@ try { */ const pubTokenPromise = sdkPromise.then(({ getDefaultKey, FREE_API_KEY }) => getDefaultKey() || FREE_API_KEY) -const apiKeySettingsPromise = sdkPromise.then(async ({ setupSdk }) => { - const sdk = await setupSdk(await pubTokenPromise) - const orgResult = await sdk.getOrganizations() - if (!orgResult.success) { - throw new Error('Failed to fetch Socket organization info: ' + orgResult.error.message) - } - /** - * @type {(Exclude)[]} - */ - const orgs = [] - for (const org of Object.values(orgResult.data.organizations)) { - if (org) { - orgs.push(org) +const apiKeySettingsInit = sdkPromise.then(async ({ setupSdk }) => { + try { + const sdk = await setupSdk(await pubTokenPromise) + const orgResult = await sdk.getOrganizations() + if (!orgResult.success) { + throw new Error('Failed to fetch Socket organization info: ' + orgResult.error.message) + } + /** + * @type {(Exclude)[]} + */ + const orgs = [] + for (const org of Object.values(orgResult.data.organizations)) { + if (org) { + orgs.push(org) + } + } + const result = await sdk.postSettings(orgs.map(org => { + return { + organization: org.id + } + })) + if (!result.success) { + throw new Error('Failed to fetch API key settings: ' + result.error.message) } - } - const result = await sdk.postSettings(orgs.map(org => { return { - organization: org.id + orgs, + settings: result.data } - })) - if (!result.success) { - throw new Error('Failed to fetch API key settings: ' + result.error.message) - } - return { - orgs, - settings: result.data + } catch (e) { + if (e && typeof e === 'object' && 'cause' in e) { + const cause = e.cause + if (isErrnoException(cause)) { + if (cause.code === 'ENOTFOUND' || cause.code === 'ECONNREFUSED') { + throw new Error('Unable to connect to socket.dev, ensure internet connectivity before retrying', { + cause: e + }) + } + } + } + throw e } }) +// mark apiKeySettingsInit as handled +apiKeySettingsInit.catch(() => {}) /** * @@ -78,42 +94,43 @@ async function findSocketYML () { const fs = require('fs/promises') while (dir !== prevDir) { const ymlPath = path.join(dir, 'socket.yml') + const yml = fs.readFile(ymlPath, 'utf-8') // mark as handled - const yml = fs.readFile(ymlPath, 'utf-8').catch(() => {}) + yml.catch(() => {}) const yamlPath = path.join(dir, 'socket.yaml') + const yaml = fs.readFile(yamlPath, 'utf-8') // mark as handled - const yaml = fs.readFile(yamlPath, 'utf-8').catch(() => {}) - try { - const txt = await yml - if (txt != null) { - return { - path: ymlPath, - parsed: config.parseSocketConfig(txt) - } - } - } catch (e) { + yaml.catch(() => {}) + /** + * @param {unknown} e + * @returns {boolean} + */ + function checkFileFoundError (e) { if (isErrnoException(e)) { if (e.code !== 'ENOENT' && e.code !== 'EISDIR') { throw e } - } else { + return false + } + return true + } + try { + return { + path: ymlPath, + parsed: config.parseSocketConfig(await yml) + } + } catch (e) { + if (checkFileFoundError(e)) { throw new Error('Found file but was unable to parse ' + ymlPath) } } try { - const txt = await yaml - if (txt != null) { - return { - path: yamlPath, - parsed: config.parseSocketConfig(txt) - } + return { + path: ymlPath, + parsed: config.parseSocketConfig(await yaml) } } catch (e) { - if (isErrnoException(e)) { - if (e.code !== 'ENOENT' && e.code !== 'EISDIR') { - throw e - } - } else { + if (checkFileFoundError(e)) { throw new Error('Found file but was unable to parse ' + yamlPath) } } @@ -124,11 +141,12 @@ async function findSocketYML () { } /** - * @type {Promise>} + * @type {Promise | undefined>} */ -const uxLookupPromise = settingsPromise.then(async ({ getSetting }) => { +const uxLookupInit = settingsPromise.then(async ({ getSetting }) => { const enforcedOrgs = getSetting('enforcedOrgs') ?? [] - const { orgs, settings } = await apiKeySettingsPromise + const remoteSettings = await apiKeySettingsInit + const { orgs, settings } = remoteSettings // remove any organizations not being enforced for (const [i, org] of orgs.entries()) { @@ -152,6 +170,8 @@ const uxLookupPromise = settingsPromise.then(async ({ getSetting }) => { } return createIssueUXLookup(settings) }) +// mark uxLookupInit as handled +uxLookupInit.catch(() => {}) // shadow `npm` and `npx` to mitigate subshells require('./link.cjs')(fs.realpathSync(path.join(__dirname, 'bin')), 'npm') @@ -506,7 +526,7 @@ async function packagesHaveRiskyIssues (safeArb, _registry, pkgs, ora = null, _i const pkgDatas = [] try { // TODO: determine org based on cwd, pass in - const uxLookup = await uxLookupPromise + const uxLookup = await uxLookupInit for await (const pkgData of batchScan(pkgs.map(pkg => pkg.pkgid))) { /** diff --git a/lib/shadow/tty-server.cjs b/lib/shadow/tty-server.cjs index 4769600..c6c657f 100644 --- a/lib/shadow/tty-server.cjs +++ b/lib/shadow/tty-server.cjs @@ -1,7 +1,8 @@ const path = require('path') const { PassThrough } = require('stream') -const { isErrnoException } = require('../utils/type-helpers.cjs') + const ipc_version = require('../../package.json').version +const { isErrnoException } = require('../utils/type-helpers.cjs') /** * @typedef {import('stream').Readable} Readable @@ -22,7 +23,7 @@ module.exports = async function createTTYServer (colorLevel, isInteractive, npml * @type {import('readline')} */ let readline - const isSTDINInteractive = true || isInteractive + const isSTDINInteractive = isInteractive if (!isSTDINInteractive && TTY_IPC) { return { async captureTTY (mutexFn) { diff --git a/lib/utils/sdk.js b/lib/utils/sdk.js index 4090cef..57ce183 100644 --- a/lib/utils/sdk.js +++ b/lib/utils/sdk.js @@ -20,15 +20,47 @@ let defaultKey /** @returns {string | undefined} */ export function getDefaultKey () { - defaultKey = getSetting('apiKey') || process.env['SOCKET_SECURITY_API_KEY'] || defaultKey + defaultKey = process.env['SOCKET_SECURITY_API_KEY'] || getSetting('apiKey') || defaultKey return defaultKey } +/** + * The API server that should be used for operations + * + * @type {string | undefined} + */ +let defaultAPIBaseUrl + +/** + * @returns {string | undefined} + */ +export function getDefaultAPIBaseUrl () { + defaultAPIBaseUrl = process.env['SOCKET_SECURITY_API_BASE_URL'] || getSetting('apiBaseUrl') || undefined + return defaultAPIBaseUrl +} + +/** + * The API server that should be used for operations + * + * @type {string | undefined} + */ +let defaultApiProxy + +/** + * @returns {string | undefined} + */ +export function getDefaultHTTPProxy () { + defaultApiProxy = process.env['SOCKET_SECURITY_API_PROXY'] || getSetting('apiProxy') || undefined + return defaultApiProxy +} + /** * @param {string} [apiKey] + * @param {string} [apiBaseUrl] + * @param {string} [proxy] * @returns {Promise} */ -export async function setupSdk (apiKey = getDefaultKey()) { +export async function setupSdk (apiKey = getDefaultKey(), apiBaseUrl = getDefaultAPIBaseUrl(), proxy = getDefaultHTTPProxy()) { if (apiKey == null && isInteractive()) { /** * @type {{ apiKey: string }} @@ -49,11 +81,11 @@ export async function setupSdk (apiKey = getDefaultKey()) { /** @type {import('@socketsecurity/sdk').SocketSdkOptions["agent"]} */ let agent - if (process.env['SOCKET_SECURITY_API_PROXY']) { + if (proxy) { const { HttpProxyAgent, HttpsProxyAgent } = await import('hpagent') agent = { - http: new HttpProxyAgent({ proxy: process.env['SOCKET_SECURITY_API_PROXY'] }), - https: new HttpsProxyAgent({ proxy: process.env['SOCKET_SECURITY_API_PROXY'] }), + http: new HttpProxyAgent({ proxy }), + https: new HttpsProxyAgent({ proxy }), } } const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), '../../package.json') @@ -62,7 +94,7 @@ export async function setupSdk (apiKey = getDefaultKey()) { /** @type {import('@socketsecurity/sdk').SocketSdkOptions} */ const sdkOptions = { agent, - baseUrl: process.env['SOCKET_SECURITY_API_BASE_URL'], + baseUrl: apiBaseUrl, userAgent: createUserAgentFromPkgJson(JSON.parse(packageJson)) } diff --git a/lib/utils/settings.js b/lib/utils/settings.js index 3e1b05d..c5d83bf 100644 --- a/lib/utils/settings.js +++ b/lib/utils/settings.js @@ -23,7 +23,7 @@ const settingsPath = path.join(dataHome, 'socket', 'settings') * @typedef {Record} IssueRules */ -/** @type {{apiKey?: string | null, enforcedOrgs?: string[] | null}} */ +/** @type {{apiKey?: string | null, enforcedOrgs?: string[] | null, apiBaseUrl?: string | null, apiProxy?: string | null}} */ let settings = {} if (fs.existsSync(settingsPath)) { diff --git a/test/socket-npm-fixtures/lacking-typosquat/package.json b/test/socket-npm-fixtures/lacking-typosquat/package.json new file mode 100644 index 0000000..9664f26 --- /dev/null +++ b/test/socket-npm-fixtures/lacking-typosquat/package.json @@ -0,0 +1,4 @@ +{ + "dependencies": { + } +} diff --git a/test/socket-npm.test.js b/test/socket-npm.test.js new file mode 100644 index 0000000..6914a96 --- /dev/null +++ b/test/socket-npm.test.js @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { describe, it } from 'node:test' +import { fileURLToPath } from 'node:url' + +const entryPath = fileURLToPath(new URL('../cli.js', import.meta.url)) + +/** + * Run relative to current file + * + * @param {object} param0 + * @param {string} param0.cwd + * @param {string[]} [param0.args] + * @param {import('node:child_process').ProcessEnvOptions['env'] | undefined} [param0.env] + * @returns {import('node:child_process').SpawnSyncReturns} + */ +function spawnNPM ({ cwd, args = [], env }) { + const pwd = fileURLToPath(new URL(cwd, import.meta.url)) + return spawnSync(process.execPath, [entryPath, 'npm', ...args], { + cwd: pwd, + encoding: 'utf-8', + env: { + ...(env ?? process.env), + // make sure we don't borrow TTY from parent + SOCKET_SECURITY_TTY_IPC: undefined + }, + stdio: ['pipe', 'pipe', 'pipe'] + }) +} + +describe('Socket npm wrapper', () => { + it('should bail on new typosquat', () => { + const ret = spawnNPM({ + cwd: './socket-npm-fixtures/lacking-typosquat', + args: ['i', 'bowserify'] + }) + assert.equal(ret.status, 1) + assert.equal(/Unable to prompt/.test(ret.stderr), true) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 22fd744..82ca65e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,9 +16,10 @@ "checkJs": true, "noEmit": true, "resolveJsonModule": true, - "module": "es2022", + "module": "ESNext", "moduleResolution": "node", - "target": "ESNext", + "lib": ["ES2022"], + "target": "es2022", "types": ["node"], /* New checks being tried out */